Compare commits

..

5 commits

Author SHA1 Message Date
zeppi
e7fca1b911 check channels and populate notifications and download queue 2022-05-11 16:32:11 -04:00
zeppi
aba062ae43 per sub download work 2022-05-05 17:28:01 -04:00
zeppi
deb3d5bdc3 local notification plumbing 2022-04-29 17:12:48 -04:00
zeppi
43dcb39045 more notifications renaming 2022-04-27 16:09:01 -04:00
zeppi
f532aa2566 rename lbryio notifications 2022-04-27 15:13:41 -04:00
286 changed files with 7723 additions and 5536 deletions

View file

@ -16,7 +16,7 @@ COMMENT_SERVER_NAME=Odysee
SEARCH_SERVER_API=https://lighthouse.odysee.com/search
SOCKETY_SERVER_API=wss://sockety.odysee.com/ws
THUMBNAIL_CDN_URL=https://image-processor.vanwanet.com/optimize/
WELCOME_VERSION=1.2
WELCOME_VERSION=1.1
# STRIPE
# STRIPE_PUBLIC_KEY='pk_test_NoL1JWL7i1ipfhVId5KfDZgo'
@ -26,15 +26,16 @@ MATOMO_URL=https://analytics.lbry.com/
MATOMO_ID=4
# OG
OG_TITLE_SUFFIX=| lbry.tv
OG_HOMEPAGE_TITLE=lbry.tv
OG_TITLE_SUFFIX=| lbry.com
OG_HOMEPAGE_TITLE=lbry.com
OG_IMAGE_URL=
SITE_CANONICAL_URL=https://lbry.tv
SITE_CANONICAL_URL=https://lbry.com
# UI
## Custom Site info
DOMAIN=lbry.tv
URL=https://lbry.tv
DOMAIN=lbry.com
CLOUD_DOMAIN=odysee.com
URL=https://lbry.com
SITE_TITLE=LBRY
SITE_NAME=LBRY
SITE_DESCRIPTION=Meet LBRY, an open, free, and community-controlled content wonderland.

View file

@ -38,22 +38,7 @@ jobs:
- uses: maxim-lobanov/setup-xcode@v1
if: startsWith(runner.os, 'mac')
with:
xcode-version: '13.1.0'
# This is gonna be hacky.
# Github made us upgrade xcode, which would force an upgrade of electron-builder to fix mac.
# But there were bugs with copyfiles / extraFiles that kept seeing duplicates erroring on ln.
# A flag USE_HARD_LINKS=false in electron-builder.json was suggested in comments, but that broke windows builds.
# So for now we'll install python2 on mac and make sure it can find it.
# Remove this after successfully upgrading electron-builder.
# HACK part 1
- uses: Homebrew/actions/setup-homebrew@master
if: startsWith(runner.os, 'mac')
# HACK part 2
- name: Install Python2
if: startsWith(runner.os, 'mac')
run: |
/bin/bash -c "$(curl -fsSL https://github.com/alfredapp/dependency-scripts/raw/main/scripts/install-python2.sh)"
echo "PYTHON_PATH=/usr/local/bin/python" >> $GITHUB_ENV
xcode-version: '12.4.0'
- name: Download blockchain headers
run: |
@ -73,7 +58,7 @@ jobs:
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
WIN_CSC_LINK: https://raw.githubusercontent.com/lbryio/lbry-desktop/master/build/cert2023.pfx
WIN_CSC_LINK: https://raw.githubusercontent.com/lbryio/lbry-desktop/master/build/cert-2021-2022.pfx
CSC_LINK: https://s3.amazonaws.com/files.lbry.io/cert/osx-csc-2021-2022.p12
# UI

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

View file

@ -1,98 +1,8 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.53.9] - [2023-2-8]
### Changed
- Updated lbrynet to [0.113.0](https://github.com/lbryio/lbry-sdk/releases/tag/v0.113.0)
## [0.53.8] - [2022-11-17]
### Fixed
- Selecting a large file in publish no longer crashes ([#7736](https://github.com/lbryio/lbry-desktop/pull/7736))
- Unfollowing unpublished channels ([#7737](https://github.com/lbryio/lbry-desktop/pull/7737))
### Changed
- Updated xcode to 13.1 and hacked a fix for release ([#7736](https://github.com/lbryio/lbry-desktop/pull/7736))
## [0.53.7] - [2022-11-10]
### Added
- 'Collections' to txo filter _community pr!_ ([#7711](https://github.com/lbryio/lbry-desktop/pull/7711))
- Swap comment servers _community pr!_ ([#7670](https://github.com/lbryio/lbry-desktop/pull/7670))
### Fixed
- Thumbnails no longer disable publish ([#7714](https://github.com/lbryio/lbry-desktop/pull/7714))
- Publishing posts were empty ([#7715](https://github.com/lbryio/lbry-desktop/pull/7715))
- Minor layout fixes _community pr!_ ([#7709](https://github.com/lbryio/lbry-desktop/pull/7709))
- Comment section buttons layout ([#7716](https://github.com/lbryio/lbry-desktop/pull/7716))
### Changed
- Removed watchman and its errors ([#7710](https://github.com/lbryio/lbry-desktop/pull/7710))
- Updated lbrynet to [0.112.0](https://github.com/lbryio/lbry-sdk/releases/tag/v0.112.0)
## [0.53.6] - [2022-10-21]
### Fixed
- Make thumbnails optional ([#7690](https://github.com/lbryio/lbry-desktop/pull/7690))
- Show downloads newest first ([#7684](https://github.com/lbryio/lbry-desktop/pull/7684))
- Only allow images in image uploader ([#7672](https://github.com/lbryio/lbry-desktop/pull/7672))
- Fixed bug with csv exports ([#7697](https://github.com/lbryio/lbry-desktop/pull/7697))
- Fixed various upload bugs including transcoding ([#7688](https://github.com/lbryio/lbry-desktop/pull/7688))
- Fallback for files with no extension ([#7704](https://github.com/lbryio/lbry-desktop/pull/7704))
### Changed
- Upgraded Electron to v17.2.0 ([#7703](https://github.com/lbryio/lbry-desktop/pull/7703))
- Upgraded Electron to v17.0.0 ([#7691](https://github.com/lbryio/lbry-desktop/pull/7691))
- Updated lbrynet to [0.111.0](https://github.com/lbryio/lbry-sdk/releases/tag/v0.111.0)
## [0.53.5] - [2022-08-26]
### Added
- Checkbox to disable background wallpaper ([#7630](https://github.com/lbryio/lbry-desktop/pull/7630))
- Handle content blocking from hub ([#7665](https://github.com/lbryio/lbry-desktop/pull/7665))
### Fixed
- Better handle decimals liquidating supports ([#7648](https://github.com/lbryio/lbry-desktop/pull/7648))
- Better handle cover uploads ([#7647](https://github.com/lbryio/lbry-desktop/pull/7647))
- Use default path when first choosing file on windows ([#7625](https://github.com/lbryio/lbry-desktop/pull/7625))
- Emoji button hover ([#7620](https://github.com/lbryio/lbry-desktop/pull/7620))
- Prevent infinite retries on thumbs ([#7618](https://github.com/lbryio/lbry-desktop/pull/7618))
- Double splash/error on app startup ([#7615](https://github.com/lbryio/lbry-desktop/pull/7615))
- App updates are now more coherent, also debs work. ([#7502](https://github.com/lbryio/lbry-desktop/pull/7502))
- Better handle many channels moderation calls at startup ([#7674](https://github.com/lbryio/lbry-desktop/pull/7674))
- Fix mobile floating viewer position ([#7677](https://github.com/lbryio/lbry-desktop/pull/7677))
### Changed
- Upgraded Electron to v15.5.5 ([#7614](https://github.com/lbryio/lbry-desktop/pull/7614))
- Upgraded to lbrynet v0.110.0 ([#7680](https://github.com/lbryio/lbry-desktop/pull/7680))
## [0.53.4] - [2022-06-10]
### Added
- Add top in language category for non-english on homepage ([#7585](https://github.com/lbryio/lbry-desktop/pull/7585))
- Auto hosting in settings and hosting first run page ([#7598](https://github.com/lbryio/lbry-desktop/pull/7598))
### Changed
- Updated lbry-sdk to [0.107.2](https://github.com/lbryio/lbry-sdk/releases/tag/v0.107.2)
### Fixed
- Better handle empty collections ([#7571](https://github.com/lbryio/lbry-desktop/pull/7571))
- Better handle thumbnails in uploads/collections ([#7574](https://github.com/lbryio/lbry-desktop/pull/7574))
- Work towards supporting collections of any claim type ([#7578](https://github.com/lbryio/lbry-desktop/pull/7578))
- Improve handling of downed custom servers on startup ([#7593](https://github.com/lbryio/lbry-desktop/pull/7593))
- Hide watch progress in related if being played ([#7606](https://github.com/lbryio/lbry-desktop/pull/7606))
- IPC disk space calls wait for daemon ready; refresh on vis. component load ([#7610](https://github.com/lbryio/lbry-desktop/pull/7610))
## [0.53.3] - [2022-04-27]
### Fixed
- Reverted lbry.tv changes that broke production login ([#7569](https://github.com/lbryio/lbry-desktop/pull/7569))
- Reverted lbry.tv changes that broke login ([#7570](https://github.com/lbryio/lbry-desktop/pull/7570))
## [0.53.2] - [2022-04-26]
### Changed
@ -100,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Removed some lbrytv references ([#7560](https://github.com/lbryio/lbry-desktop/pull/7560))
- Removed some lbrytv player references ([#7552](https://github.com/lbryio/lbry-desktop/pull/7552))
### Fixed
## Fixed
- Repost style issues ([#7559](https://github.com/lbryio/lbry-desktop/pull/7559))
- Disappearing sidebar thumbs ([#7556](https://github.com/lbryio/lbry-desktop/pull/7556))
- Restore tags sidebar link ([#7555](https://github.com/lbryio/lbry-desktop/pull/7555))

View file

@ -65,19 +65,21 @@ _Note: If coming from a deb install, the directory structure is different and yo
| | Flatpak | Arch | Nixpkgs | ARM/ARM64 |
| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------- |
| Latest Release | [FlatHub Page](https://flathub.org/apps/details/io.lbry.lbry-app) | [AUR Package](https://aur.archlinux.org/packages/lbry-desktop-bin/) | [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=lbry&query=lbry) | [Build Guide](https://lbry.tv/@LBRYarm:5) |
| Maintainers | N/A | [@RubenKelevra](https://github.com/RubenKelevra) | [@Enderger](https://github.com/enderger) | [@Madiator2011](https://github.com/kodxana) |
| Latest Release | [FlatHub Page](https://flathub.org/apps/details/io.lbry.lbry-app) | [AUR Package](https://aur.archlinux.org/packages/lbry-app-bin/) | [Nixpkgs](https://search.nixos.org/packages?channel=unstable&show=lbry&query=lbry) | [Build Guide](https://lbry.tv/@LBRYarm:5) |
| Maintainers | [@kcSeb](https://keybase.io/kcseb) | [@kcSeb](https://keybase.io/kcseb) | [@Enderger](https://github.com/enderger) | [@Madiator2011](https://github.com/kodxana) |
## Usage
Start the installed application to interact with the LBRY network.
Double click the installed application to interact with the LBRY network.
## Running from Source
You can run the web version (lbry.tv), the electron app, or both at the same time.
#### Prerequisites
- [Git](https://git-scm.com/downloads)
- [Node.js](https://nodejs.org/en/download/) (v16 required)
- [Node.js](https://nodejs.org/en/download/) (v14 required)
- [Corepack](https://nodejs.org/dist/latest-v17.x/docs/api/corepack.html) `npm i -g corepack` (Included in nodejs 14 LTS, 16 LTS and 17)
- [Yarn](https://yarnpkg.com/en/docs/install)

Binary file not shown.

View file

@ -21,6 +21,7 @@ const config = {
WELCOME_VERSION: process.env.WELCOME_VERSION,
DOMAIN: process.env.DOMAIN,
SHARE_DOMAIN_URL: process.env.SHARE_DOMAIN_URL,
CLOUD_DOMAIN: process.env.CLOUD_DOMAIN,
URL: process.env.URL,
THUMBNAIL_CDN_URL: process.env.THUMBNAIL_CDN_URL,
SITE_TITLE: process.env.SITE_TITLE,

View file

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

View file

@ -20,12 +20,8 @@ import path from 'path';
import { diskSpaceLinux, diskSpaceWindows, diskSpaceMac } from '../ui/util/diskspace';
const { download } = require('electron-dl');
const mime = require('mime');
const remote = require('@electron/remote/main');
const os = require('os');
const sudo = require('sudo-prompt');
const probe = require('ffmpeg-probe');
const MAX_IPC_SEND_BUFFER_SIZE = 500000000; // large files crash when serialized for ipc message
remote.initialize();
const filePath = path.join(process.resourcesPath, 'static', 'upgradeDisabled');
@ -37,23 +33,29 @@ try {
upgradeDisabled = false;
}
autoUpdater.autoDownload = !upgradeDisabled;
autoUpdater.allowPrerelease = false;
const UPDATE_STATE_INIT = 0;
const UPDATE_STATE_CHECKING = 1;
const UPDATE_STATE_UPDATES_FOUND = 2;
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 set to true if an auto update has been downloaded through the Electron
// auto-update system and is ready to install. If the user declined an update earlier,
// it will still install on shutdown.
let autoUpdateDownloaded = false;
// 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.
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
// object is garbage collected.
let rendererWindow;
@ -241,8 +243,7 @@ app.on('activate', () => {
app.on('will-quit', event => {
if (
process.platform === 'win32' &&
updateState === UPDATE_STATE_DOWNLOADED &&
isAutoUpdateSupported &&
autoUpdateDownloaded &&
!appState.autoUpdateAccepted &&
!showingAutoUpdateCloseAlert
) {
@ -302,96 +303,6 @@ app.on('before-quit', () => {
appState.isQuitting = true;
});
// Get the content of a file as a raw buffer of bytes.
// Useful to convert a file path to a File instance.
// Example:
// const result = await ipcMain.invoke('get-file-from-path', 'path/to/file');
// const file = new File([result.buffer], result.name);
// NOTE: if path points to a folder, an empty
// file will be given.
ipcMain.handle('get-file-from-path', (event, path, readContents = true) => {
return new Promise((resolve, reject) => {
fs.stat(path, (error, stats) => {
if (error) {
reject(error);
return;
}
// Separate folders considering "\" and "/"
// as separators (cross platform)
const folders = path.split(/[\\/]/);
const name = folders[folders.length - 1];
if (stats.isDirectory()) {
resolve({
name,
mime: undefined,
path,
buffer: new ArrayBuffer(0),
});
return;
}
if (!readContents) {
resolve({
name,
mime: mime.getType(name) || undefined,
path,
buffer: new ArrayBuffer(0),
});
return;
}
// Encoding null ensures data results in a Buffer.
fs.readFile(path, { encoding: null }, (err, data) => {
if (err) {
reject(err);
return;
}
resolve({
name,
mime: mime.getType(name) || undefined,
path,
buffer: data,
});
});
});
});
});
ipcMain.handle('get-file-details-from-path', async (event, path) => {
const isFfMp4 = (ffprobeResults) => {
return ffprobeResults &&
ffprobeResults.format &&
ffprobeResults.format.format_name &&
ffprobeResults.format.format_name.includes('mp4');
};
const folders = path.split(/[\\/]/);
const name = folders[folders.length - 1];
let duration = 0, size = 0, mimeType;
try {
await fs.promises.stat(path);
let ffprobeResults;
try {
ffprobeResults = await probe(path);
duration = ffprobeResults.format.duration;
size = ffprobeResults.format.size;
} catch (e) {
}
let fileReadResult;
if (size < MAX_IPC_SEND_BUFFER_SIZE) {
try {
fileReadResult = await fs.promises.readFile(path);
} catch (e) {
}
}
// TODO: use mmmagic to inspect file and get mime type
mimeType = isFfMp4(ffprobeResults) ? 'video/mp4' : mime.getType(name);
const fileData = {name, mime: mimeType || undefined, path, duration: duration, size, buffer: fileReadResult };
return fileData;
} catch (e) {
// no stat
return { error: 'no file' };
}
});
ipcMain.on('get-disk-space', async (event) => {
try {
const { data_dir } = await Lbry.settings_get();
@ -412,10 +323,91 @@ ipcMain.on('get-disk-space', async (event) => {
rendererWindow.webContents.send('send-disk-space', { diskSpace });
} catch (e) {
rendererWindow.webContents.send('send-disk-space', { error: e.message || e });
console.log('Failed to get disk space', e);
console.log('Failed to start LbryFirst', e);
}
});
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', () => {
function formatRc(ver) {
// Adds dash if needed to make RC suffix SemVer friendly
@ -508,162 +500,3 @@ process.on('uncaughtException', error => {
if (daemon) daemon.quit();
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

@ -155,10 +155,7 @@ Lbryio.authenticate = (domain, language) => {
.then(
status =>
new Promise((res, rej) => {
const appId =
domain && domain !== 'lbry.tv'
? (domain.replace(/[.]/gi, '') + status.installation_id).slice(0, 66)
: status.installation_id;
const appId = status.installation_id;
Lbryio.call(
'user',
'new',

View file

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

38
flow-typed/Claim.js vendored
View file

@ -67,6 +67,7 @@ declare type GenericMetadata = {
languages?: Array<string>,
tags?: Array<string>,
locations?: Array<Location>,
release_time?: number, // this shouldn't be here, but... fixme?
};
declare type ChannelMetadata = GenericMetadata & {
@ -145,49 +146,12 @@ declare type PurchaseReceipt = {
type: 'purchase',
};
declare type ClaimErrorCensor = {
address: string,
amount: string,
canonical_url: string,
claim_id: string,
claim_op: string,
confirmations: number,
has_signing_key: boolean,
height: number,
meta: {
activation_height: number,
claims_in_channel: number,
creation_height: number,
creation_timestamp: number,
effective_amount: string,
expiration_height: number,
is_controlling: boolean,
reposted: number,
support_amount: string,
take_over_height: number,
},
name: string,
normalized_name: string,
nout: number,
permanent_url: string,
short_url: string,
timestamp: number,
txid: string,
type: string,
value: {
public_key: string,
public_key_id: string,
},
value_type: string,
}
declare type ClaimActionResolveInfo = {
[string]: {
stream: ?StreamClaim,
channel: ?ChannelClaim,
claimsInChannel: ?number,
collection: ?CollectionClaim,
errorCensor: ?ClaimErrorCensor,
},
}

View file

@ -8,6 +8,6 @@ declare type WalletServerDetails = {
};
declare type DiskSpace = {
total: number,
free: number,
total: string,
free: string,
};

View file

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

View file

@ -1,9 +0,0 @@
// @flow
declare type FileWithPath = {
file: File,
// The full path will only be available in
// the application. For browser, the name
// of the file will be used.
path: string,
}

View file

@ -22,7 +22,6 @@ declare type RowDataItem = {
channelIds?: Array<string>,
limitClaimsPerChannel?: number,
pageSize?: number,
languages?: Array<string>,
},
route?: string,
hideForUnauth?: boolean,

View file

@ -16,4 +16,7 @@ declare type SubscriptionState = {
following: Array<Following>,
loading: boolean,
firstRunCompleted: boolean,
downloadEnabledByUrl: any,
downloadQueue: Array<string>,
lastReleaseBySubUrl: any,
};

6
flow-typed/web-file.js vendored Normal file
View file

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

View file

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

View file

@ -2206,6 +2206,13 @@
"Enabling a minimum amount to comment will force all comments to have tips associated with them. This can help prevent spam.": "Enabling a minimum amount to comment will force all comments to have tips associated with them. This can help prevent spam.",
"Comments containing these words will be blocked.": "Comments containing these words will be blocked.",
"Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8": "Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8",
"Disk Space": "Disk Space",
"Data Hosting": "Data Hosting",
"Limit": "Limit",
"Limit Space Used": "Limit Space Used",
"Apply": "Apply",
"Limit in GB": "Limit in GB",
"If you set a limit, playing videos may exceed your limit until cleanup runs every 30 minutes.": "If you set a limit, playing videos may exceed your limit until cleanup runs every 30 minutes.",
"Enable Data Hosting": "Enable Data Hosting",
"Data over the limit will be deleted within 30 minutes. This will make the Yrbl cry a little bit.": "Data over the limit will be deleted within 30 minutes. This will make the Yrbl cry a little bit.",
"Choose %asset%": "Choose %asset%",
@ -2221,6 +2228,13 @@
"Enable Prerelease Updates": "Enable Prerelease Updates",
"Enable Upgrade to Test Builds": "Enable Upgrade to Test Builds",
"Prereleases may break things and we may not be able to fix them for you.": "Prereleases may break things and we may not be able to fix them for you.",
"Limit (GB)": "Limit (GB)",
"Limit Hosting for Content you Use": "Limit Hosting for Content you Use",
"Allow (GB)": "Allow (GB)",
"Content Data Hosting helps to seed things that you watch and download.": "Content Data Hosting helps to seed things that you watch and download.",
"Network Data Hosting allows the p2p network to store blobs unrelated to your browsing.": "Network Data Hosting allows the p2p network to store blobs unrelated to your browsing.",
"Content: Limit (GB)": "Content: Limit (GB)",
"Network: Allow (GB)": "Network: Allow (GB)",
"A channel is required to repost on LBRY": "A channel is required to repost on LBRY",
"Admin": "Admin",
"Stickers": "Stickers",
@ -2242,6 +2256,19 @@
"Move Up": "Move Up",
"Move Down": "Move Down",
"Trending for #Game": "Trending for #Game",
"Help the P2P data network by hosting data.": "Help the P2P data network by hosting data.",
"History hosting lets you choose how much storage to use helping content you've consumed.": "History hosting lets you choose how much storage to use helping content you've consumed.",
"Automatic hosting lets you delegate some amount of storage for the network to automatically download ad host.": "Automatic hosting lets you delegate some amount of storage for the network to automatically download ad host.",
"Playing videos may exceed your history hosting limit until cleanup runs every 30 minutes.": "Playing videos may exceed your history hosting limit until cleanup runs every 30 minutes.",
"History: Limit (GB)": "History: Limit (GB)",
"Automatic: Allow (GB)": "Automatic: Allow (GB)",
"Automatic hosting lets you delegate some amount of storage for the network to automatically download and host.": "Automatic hosting lets you delegate some amount of storage for the network to automatically download and host.",
"History Hosting": "History Hosting",
"Automatic Hosting": "Automatic Hosting",
"History Hosting lets you choose how much storage to use helping content you've consumed.": "History Hosting lets you choose how much storage to use helping content you've consumed.",
"Automatic Hosting lets you delegate some amount of storage for the network to automatically download and host.": "Automatic Hosting lets you delegate some amount of storage for the network to automatically download and host.",
"Help improve the P2P data network (and make LBRY happy) by hosting data.": "Help improve the P2P data network (and make LBRY happy) by hosting data.",
"Limit Hosting of Content History": "Limit Hosting of Content History",
"Remove custom comment server": "Remove custom comment server",
"Use Https": "Use Https",
"Server URL": "Server URL",
@ -2288,39 +2315,7 @@
"Clear Views": "Clear Views",
"Show Video View Progress": "Show Video View Progress",
"Display view progress on thumbnail. This setting will not hide any blockchain activity or downloads.": "Display view progress on thumbnail. This setting will not hide any blockchain activity or downloads.",
"Content Hosting": "Content Hosting",
"Hosting": "Hosting",
"Viewed Hosting": "Viewed Hosting",
"Auto Hosting": "Auto Hosting",
"Help creators and improve the P2P data network by hosting content.": "Help creators and improve the P2P data network by hosting content.",
"I'm happy with my settings": "I'm happy with my settings",
"We've noticed you already have some settings.": "We've noticed you already have some settings.",
"You choose how much data to host.": "You choose how much data to host.",
"Go back": "Go back",
"Custom Hosting": "Custom Hosting",
"Automatic Hosting (GB)": "Automatic Hosting (GB)",
"* Note that as peer-to-peer software, your IP address and potentially other system information can be sent to other users, though this information is not stored permanently.": "* Note that as peer-to-peer software, your IP address and potentially other system information can be sent to other users, though this information is not stored permanently.",
"Help improve the P2P data network (and make LBRY users happy) by hosting data.": "Help improve the P2P data network (and make LBRY users happy) by hosting data.",
"View History Hosting lets you choose how much storage to use hosting content you've consumed.": "View History Hosting lets you choose how much storage to use hosting content you've consumed.",
"Automatic Hosting downloads a small portion of content active on the network.": "Automatic Hosting downloads a small portion of content active on the network.",
"Publishes --[legend, storage category]--": "Publishes",
"Auto Hosting --[legend, storage category]--": "Auto Hosting",
"View Hosting --[legend, storage category]--": "View Hosting",
"%spaceUsed% of %limit% GB": "%spaceUsed% of %limit% GB",
"%spaceUsed% of %limit% Free GB": "%spaceUsed% of %limit% Free GB",
"Disabled": "Disabled",
"Free --[legend, unused disk space]--": "Free",
"Top content in %language%": "Top content in %language%",
"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.",
"%anonymous%": "%anonymous%",
"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--"
}

View file

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

View file

@ -1,11 +1,4 @@
// @flow
/*
Removed Watchman (internal view tracking) code.
This file may eventually implement cantina
Refer to 0cc0e213a5c5bf9e2a76316df5d9da4b250a13c3 for initial integration commit
refer to ___ for removal commit.
*/
import { Lbryio } from 'lbryinc';
import * as Sentry from '@sentry/browser';
import MatomoTracker from '@datapunt/matomo-tracker-js';
@ -21,9 +14,12 @@ const devInternalApis = process.env.LBRY_API_URL && process.env.LBRY_API_URL.inc
export const SHARE_INTERNAL = 'shareInternal';
const SHARE_THIRD_PARTY = 'shareThirdParty';
const WATCHMAN_BACKEND_ENDPOINT = 'https://watchman.na-backend.odysee.com/reports/playback';
// const SEND_DATA_TO_WATCHMAN_INTERVAL = 10; // in seconds
if (isProduction) {
ElectronCookies.enable({
origin: 'https://lbry.tv',
origin: 'https://lbry.com',
});
}
@ -72,10 +68,114 @@ type LogPublishParams = {
let internalAnalyticsEnabled: boolean = false;
if (window.localStorage.getItem(SHARE_INTERNAL) === 'true') internalAnalyticsEnabled = true;
/**
* Determine the mobile device type viewing the data
* This function returns one of 'and' (Android), 'ios', or 'web'.
*
* @returns {String}
*/
function getDeviceType() {
return 'dsk';
}
// variables initialized for watchman
let amountOfBufferEvents = 0;
let amountOfBufferTimeInMS = 0;
let videoType, userId, claimUrl, playerPoweredBy, videoPlayer, bitrateAsBitsPerSecond;
let lastSentTime;
// calculate data for backend, send them, and reset buffer data for next interval
async function sendAndResetWatchmanData() {
if (!userId) {
return 'Can only be used with a user id';
}
if (!videoPlayer) {
return 'Video player not initialized';
}
let timeSinceLastIntervalSend = new Date() - lastSentTime;
lastSentTime = new Date();
let protocol;
if (videoType === 'application/x-mpegURL') {
protocol = 'hls';
// get bandwidth if it exists from the texttrack (so it's accurate if user changes quality)
// $FlowFixMe
bitrateAsBitsPerSecond = videoPlayer.textTracks?.().tracks_[0]?.activeCues[0]?.value?.bandwidth;
} else {
protocol = 'stb';
}
// current position in video in MS
const positionInVideo = Math.round(videoPlayer.currentTime()) * 1000;
// get the duration marking the time in the video for relative position calculation
const totalDurationInSeconds = Math.round(videoPlayer.duration());
// build object for watchman backend
const objectToSend = {
rebuf_count: amountOfBufferEvents,
rebuf_duration: amountOfBufferTimeInMS,
url: claimUrl.replace('lbry://', ''),
device: getDeviceType(),
duration: timeSinceLastIntervalSend,
protocol,
player: playerPoweredBy,
user_id: userId.toString(),
position: Math.round(positionInVideo),
rel_position: Math.round((positionInVideo / (totalDurationInSeconds * 1000)) * 100),
bitrate: bitrateAsBitsPerSecond,
bandwidth: undefined,
// ...(userDownloadBandwidthInBitsPerSecond && {bandwidth: userDownloadBandwidthInBitsPerSecond}), // add bandwidth if populated
};
// post to watchman
await sendWatchmanData(objectToSend);
// reset buffer data
amountOfBufferEvents = 0;
amountOfBufferTimeInMS = 0;
}
let watchmanInterval;
// clear watchman interval and mark it as null (when video paused)
function stopWatchmanInterval() {
clearInterval(watchmanInterval);
watchmanInterval = null;
}
// creates the setInterval that will run send to watchman on recurring basis
function startWatchmanIntervalIfNotRunning() {
if (!watchmanInterval) {
// instantiate the first time to calculate duration from
lastSentTime = new Date();
}
}
// post data to the backend
async function sendWatchmanData(body) {
try {
const response = await fetch(WATCHMAN_BACKEND_ENDPOINT, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
return response;
} catch (err) {}
}
const analytics: Analytics = {
// receive buffer events from tracking plugin and save buffer amounts and times for backend call
videoBufferEvent: async (claim, data) => {
// stub
amountOfBufferEvents = amountOfBufferEvents + 1;
amountOfBufferTimeInMS = amountOfBufferTimeInMS + data.bufferDuration;
},
onDispose: () => {
stopWatchmanInterval();
},
/**
* Is told whether video is being started or paused, and adjusts interval accordingly
@ -83,9 +183,40 @@ const analytics: Analytics = {
* @param {object} passedPlayer - VideoJS Player object
*/
videoIsPlaying: (isPlaying, passedPlayer) => {
// stub
let playerIsSeeking = false;
// have to use this because videojs pauses/unpauses during seek
// sometimes the seeking function isn't populated yet so check for it as well
if (passedPlayer && passedPlayer.seeking) {
playerIsSeeking = passedPlayer.seeking();
}
// if being paused, and not seeking, send existing data and stop interval
if (!isPlaying && !playerIsSeeking) {
sendAndResetWatchmanData();
stopWatchmanInterval();
// if being told to pause, and seeking, send and restart interval
} else if (!isPlaying && playerIsSeeking) {
sendAndResetWatchmanData();
stopWatchmanInterval();
startWatchmanIntervalIfNotRunning();
// is being told to play, and seeking, don't do anything,
// assume it's been started already from pause
} else if (isPlaying && playerIsSeeking) {
// start but not a seek, assuming a start from paused content
} else if (isPlaying && !playerIsSeeking) {
startWatchmanIntervalIfNotRunning();
}
},
videoStartEvent: (claimId, timeToStartVideo, poweredBy, passedUserId, canonicalUrl, passedPlayer, videoBitrate) => {
// populate values for watchman when video starts
userId = passedUserId;
claimUrl = canonicalUrl;
playerPoweredBy = poweredBy;
videoType = passedPlayer.currentSource().type;
videoPlayer = passedPlayer;
bitrateAsBitsPerSecond = videoBitrate;
// sendPromMetric('time_to_start', duration);
sendMatomoEvent('Media', 'TimeToStart', claimId, timeToStartVideo);
},
@ -251,9 +382,24 @@ function sendMatomoEvent(category, action, name, value) {
}
}
// Prometheus
// function sendPromMetric(name: string, value?: number) {
// if (IS_WEB) {
// let url = new URL(SDK_API_PATH + '/metric/ui');
// const params = { name: name, value: value ? value.toString() : '' };
// url.search = new URLSearchParams(params).toString();
// return fetch(url, { method: 'post' });
// }
// }
const MatomoInstance = new MatomoTracker({
urlBase: MATOMO_URL,
siteId: MATOMO_ID, // optional, default value: `1`
// heartBeat: { // optional, enabled by default
// active: true, // optional, default value: true
// seconds: 10 // optional, default value: `15
// },
// linkTracking: false // optional, default value: true
});
analytics.pageView(generateInitialUrl(window.location.hash));

View file

@ -143,6 +143,7 @@ function App(props: Props) {
const shouldMigrateLanguage = LANGUAGE_MIGRATIONS[language];
const hasActiveChannelClaim = activeChannelId !== undefined;
const isPersonalized = hasVerifiedEmail;
const renderFiledrop = isAuthenticated;
useEffect(() => {
if (userId) {
@ -348,7 +349,7 @@ function App(props: Props) {
>
<Router />
<ModalRouter />
<FileDrop />
{renderFiledrop && <FileDrop />}
<FileRenderFloating />
{isEnhancedLayout && <Yrbl className="yrbl--enhanced" />}

View file

@ -1,21 +0,0 @@
import { connect } from 'react-redux';
import StorageViz from './view';
import {
selectViewBlobSpace,
selectViewHostingLimit,
selectAutoBlobSpace,
selectPrivateBlobSpace,
selectAutoHostingLimit,
} from 'redux/selectors/settings';
import { selectDiskSpace } from 'redux/selectors/app';
const select = (state) => ({
diskSpace: selectDiskSpace(state),
viewHostingLimit: selectViewHostingLimit(state),
autoHostingLimit: selectAutoHostingLimit(state),
viewBlobSpace: selectViewBlobSpace(state),
autoBlobSpace: selectAutoBlobSpace(state),
privateBlobSpace: selectPrivateBlobSpace(state),
});
export default connect(select)(StorageViz);

View file

@ -1,130 +0,0 @@
// @flow
import * as React from 'react';
import I18nMessage from 'component/i18nMessage';
import { ipcRenderer } from 'electron';
type Props = {
// --- select ---
diskSpace: DiskSpace, // KB
viewHostingLimit: number, // MB
autoHostingLimit: number,
viewBlobSpace: number,
autoBlobSpace: number,
privateBlobSpace: number,
};
function StorageViz(props: Props) {
const { diskSpace, viewHostingLimit, autoHostingLimit, viewBlobSpace, autoBlobSpace, privateBlobSpace } = props;
React.useEffect(() => {
ipcRenderer.send('get-disk-space');
}, []);
if (!diskSpace || !diskSpace.total) {
return (
<div className={'storage__wrapper'}>
<div className={'storage__bar'}>
<div className="help">{__('Cannot get disk space information.')}</div>
</div>
</div>
);
}
const totalMB = diskSpace && Math.floor(diskSpace.total / 1024);
const freeMB = diskSpace && Math.floor(diskSpace.free / 1024);
const otherMB = totalMB - (freeMB + viewBlobSpace + autoBlobSpace + privateBlobSpace);
const autoFree = autoHostingLimit - autoBlobSpace;
const viewFree = viewHostingLimit > 0 ? viewHostingLimit - viewBlobSpace : freeMB - autoFree;
const unallocFree = freeMB - viewFree - autoFree;
const viewLimit =
viewHostingLimit === 0
? freeMB - (autoHostingLimit - autoBlobSpace) + viewBlobSpace
: viewHostingLimit + viewBlobSpace;
const getPercent = (val, lim = totalMB) => (val / lim) * 100;
const getGB = (val) => (Number(val) / 1024).toFixed(2);
const otherPercent = getPercent(otherMB);
const privatePercent = getPercent(privateBlobSpace);
const autoLimitPercent = getPercent(autoHostingLimit);
const viewLimitPercent = getPercent(viewLimit);
const viewUsedPercentOfLimit = getPercent(viewBlobSpace, viewLimit);
const autoUsedPercentOfLimit = getPercent(autoBlobSpace, autoHostingLimit);
return (
<div className={'storage__wrapper'}>
<div className={'storage__bar'}>
<div className={'storage__other'} style={{ width: `${otherPercent}%` }} />
<div className={'storage__private'} style={{ width: `${privatePercent}%` }} />
<div className={'storage__auto'} style={{ width: `${autoLimitPercent}%` }}>
<div className={'storage__auto--used'} style={{ width: `${autoUsedPercentOfLimit}%` }} />
<div className={'storage__auto--free'} />
</div>
<div className={'storage__viewed'} style={{ width: `${viewLimitPercent}%` }}>
<div className={'storage__viewed--used'} style={{ width: `${viewUsedPercentOfLimit}%` }} />
<div className={'storage__viewed--free'} />
</div>
{viewHostingLimit !== 0 && <div style={{ 'background-color': 'unset' }} />}
</div>
<div className={'storage__legend-wrapper'}>
<div className={'storage__legend-item'}>
<div className={'storage__legend-item-swatch storage__legend-item-swatch--private'} />
<div className={'storage__legend-item-label'}>
<label>{__('Publishes --[legend, storage category]--')}</label>
<div className={'help'}>{`${getGB(privateBlobSpace)} GB`}</div>
</div>
</div>
<div className={'storage__legend-item'}>
<div className={'storage__legend-item-swatch storage__legend-item-swatch--auto'} />
<div className={'storage__legend-item-label'}>
<label>{__('Auto Hosting --[legend, storage category]--')}</label>
<div className={'help'}>
{autoHostingLimit === 0 ? (
__('Disabled')
) : (
<I18nMessage
tokens={{
spaceUsed: getGB(autoBlobSpace),
limit: getGB(autoHostingLimit),
}}
>
%spaceUsed% of %limit% GB
</I18nMessage>
)}
</div>
</div>
</div>
<div className={'storage__legend-item'}>
<div className={'storage__legend-item-swatch storage__legend-item-swatch--viewed'} />
<div className={'storage__legend-item-label'}>
<label>{__('View Hosting --[legend, storage category]--')}</label>
<div className={'help'}>
{viewHostingLimit === 1 ? (
__('Disabled')
) : (
<I18nMessage
tokens={{
spaceUsed: getGB(viewBlobSpace),
limit: viewHostingLimit !== 0 ? getGB(viewHostingLimit) : getGB(viewFree),
}}
>
%spaceUsed% of %limit% Free GB
</I18nMessage>
)}
</div>
</div>
</div>
{viewHostingLimit !== 0 && (
<div className={'storage__legend-item'}>
<div className={'storage__legend-item-swatch storage__legend-item-swatch--free'} />
<div className={'storage__legend-item-label'}>
<label>{__('Free --[legend, unused disk space]--')}</label>
<div className={'help'}>{`${getGB(unallocFree)} GB`}</div>
</div>
</div>
)}
</div>
</div>
);
}
export default StorageViz;

View file

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

View file

@ -6,7 +6,6 @@ import CreditAmount from 'component/common/credit-amount';
import DateTime from 'component/dateTime';
import YoutubeBadge from 'component/youtubeBadge';
import SUPPORTED_LANGUAGES from 'constants/supported_languages';
import { formatNumber } from 'util/number';
type Props = {
claim: ChannelClaim,
@ -75,7 +74,7 @@ function ChannelAbout(props: Props) {
</div>
<label>{__('Total Uploads')}</label>
<div className="media__info-text">{formatNumber(claim.meta.claims_in_channel || 0, 2, true)}</div>
<div className="media__info-text">{claim.meta.claims_in_channel}</div>
<label>{__('Last Updated')}</label>
<div className="media__info-text">

View file

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

View file

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

View file

@ -110,11 +110,6 @@ function ClaimMenuList(props: Props) {
const [doShuffle, setDoShuffle] = React.useState(false);
const incognitoClaim = contentChannelUri && !contentChannelUri.includes('@');
const isChannel = !incognitoClaim && !contentSigningChannel;
// $FlowFixMe
const claimLength = claim && claim.value && claim.value.claims && claim.value.claims.length;
// $FlowFixMe
const claimCount = editedCollection ? editedCollection.items.length : claimLength;
const isEmptyCollection = (Number(claimCount) || 0) <= 0;
const { channelName } = parseURI(contentChannelUri);
const showDelete = claimIsMine || (fileInfo && (fileInfo.written_bytes > 0 || fileInfo.blobs_completed > 0));
const subscriptionLabel = repostedClaim
@ -302,7 +297,6 @@ function ClaimMenuList(props: Props) {
{__('View List')}
</a>
</MenuItem>
{!isEmptyCollection && (
<MenuItem
className="comment__menu-option"
onSelect={() => {
@ -315,7 +309,6 @@ function ClaimMenuList(props: Props) {
{__('Shuffle Play')}
</div>
</MenuItem>
)}
{isMyCollection && (
<>
<MenuItem

View file

@ -9,7 +9,7 @@ import * as COLLECTIONS_CONSTS from 'constants/collections';
import { isChannelClaim } from 'util/claim';
import { formatLbryUrlForWeb } from 'util/url';
import { formatClaimPreviewTitle } from 'util/formatAriaLabel';
import { formatNumber } from 'util/number';
import { toCompactNotation } from 'util/string';
import Tooltip from 'component/common/tooltip';
import FileThumbnail from 'component/fileThumbnail';
import UriIndicator from 'component/uriIndicator';
@ -138,6 +138,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
indexInContainer,
channelSubCount,
swipeLayout = false,
lang,
showEdit,
dragHandleProps,
unavailableUris,
@ -161,8 +162,8 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
if (channelSubCount === undefined) {
return <span />;
}
const formattedSubCount = formatNumber(channelSubCount, 2, true);
const formattedSubCountLocale = formatNumber(channelSubCount, 2, false);
const formattedSubCount = toCompactNotation(channelSubCount, lang, 10000);
const formattedSubCountLocale = Number(channelSubCount).toLocaleString();
return (
<div className="media__subtitle">
<Tooltip title={formattedSubCountLocale} followCursor placement="top">

View file

@ -5,7 +5,6 @@ import DateTime from 'component/dateTime';
import Button from 'component/button';
import FileViewCountInline from 'component/fileViewCountInline';
import { parseURI } from 'util/lbryURI';
import { formatNumber } from 'util/number';
type Props = {
uri: string,
@ -35,7 +34,7 @@ function ClaimPreviewSubtitle(props: Props) {
<>
{isChannel &&
type !== 'inline' &&
`${formatNumber(claimsInChannel, 2, true)} ${claimsInChannel === 1 ? __('upload') : __('uploads')}`}
`${claimsInChannel} ${claimsInChannel === 1 ? __('upload') : __('uploads')}`}
{!isChannel && (
<>

View file

@ -54,13 +54,9 @@ function CollectionActions(props: Props) {
const isMobile = useIsMobile();
const claimId = claim && claim.claim_id;
const webShareable = true; // collections have cost?
const isEmptyCollection = !firstItem;
const doPlay = React.useCallback(
(playUri) => {
if (!playUri) {
return;
}
const navigateUrl = formatLbryUrlForWeb(playUri);
push({
pathname: navigateUrl,
@ -85,7 +81,6 @@ function CollectionActions(props: Props) {
icon={ICONS.PLAY}
label={__('Play')}
title={__('Play')}
disabled={isEmptyCollection}
onClick={() => {
doToggleShuffleList(collectionId, false);
doPlay(firstItem);
@ -96,7 +91,6 @@ function CollectionActions(props: Props) {
icon={ICONS.SHUFFLE}
label={__('Shuffle Play')}
title={__('Shuffle Play')}
disabled={isEmptyCollection}
onClick={() => {
doToggleShuffleList(collectionId, true);
setDoShuffle(true);

View file

@ -23,7 +23,6 @@ import CollectionForm from './view';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { doSetActiveChannel, doSetIncognito } from 'redux/actions/app';
import { doCollectionEdit } from 'redux/actions/collections';
import { doResetThumbnailStatus } from 'redux/actions/publish';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
@ -53,7 +52,6 @@ const perform = (dispatch, ownProps) => ({
setActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)),
setIncognito: (incognito) => dispatch(doSetIncognito(incognito)),
doCollectionEdit: (params) => dispatch(doCollectionEdit(ownProps.collectionId, params)),
resetThumbnailStatus: () => dispatch(doResetThumbnailStatus()),
});
export default connect(select, perform)(CollectionForm);

View file

@ -17,7 +17,7 @@ import { useHistory } from 'react-router-dom';
import { isNameValid, regexInvalidURI } from 'util/lbryURI';
import * as THUMBNAIL_STATUSES from 'constants/thumbnail_upload_statuses';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import { FormField, FormFieldAreaAdvanced } from 'component/common/form';
import { FormField } from 'component/common/form';
import { handleBidChange } from 'util/publish';
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import { INVALID_NAME_ERROR } from 'constants/claim';
@ -58,7 +58,6 @@ type Props = {
setActiveChannel: (string) => void,
setIncognito: (boolean) => void,
doCollectionEdit: (CollectionEditParams) => void,
resetThumbnailStatus: () => void,
};
function CollectionForm(props: Props) {
@ -93,7 +92,6 @@ function CollectionForm(props: Props) {
setIncognito,
onDone,
doCollectionEdit,
resetThumbnailStatus,
} = props;
const activeChannelName = activeChannelClaim && activeChannelClaim.name;
let prefix = 'lbry://';
@ -160,7 +158,7 @@ function CollectionForm(props: Props) {
}
function handleUpdateThumbnail(update: { [string]: string }) {
if (update.thumbnail_url !== undefined) {
if (update.thumbnail_url) {
setParam(update);
} else if (update.thumbnail_status) {
setThumbStatus(update.thumbnail_status);
@ -311,10 +309,6 @@ function CollectionForm(props: Props) {
}
}, [uri, hasClaim]);
React.useEffect(() => {
resetThumbnailStatus();
}, [resetThumbnailStatus]);
return (
<>
<div className={classnames('main--contained', { 'card--disabled': disabled })}>
@ -364,14 +358,13 @@ function CollectionForm(props: Props) {
/>
<fieldset-section>
<SelectThumbnail
thumbnail={params.thumbnail_url}
thumbnailError={thumbError}
thumbnailParam={params.thumbnail_url}
thumbnailParamError={thumbError}
thumbnailParamStatus={thumbStatus}
updateThumbnailParams={handleUpdateThumbnail}
usePublishFormMode
/>
</fieldset-section>
<FormFieldAreaAdvanced
<FormField
type="markdown"
name="content_description2"
label={__('Description')}

View file

@ -33,7 +33,7 @@ function CollectionPreviewOverlay(props: Props) {
collectionItemUrls.map((item, index) => {
if (index < 2) {
return (
<div key={item} className="collection-preview__overlay-grid-items">
<div className="collection-preview__overlay-grid-items">
<FileThumbnail uri={item} key={item} />
</div>
);

View file

@ -95,7 +95,7 @@ export default function CollectionsListMine(props: Props) {
{builtin.map((list: Collection) => {
const { items: itemUrls } = list;
return (
<React.Fragment key={list.name}>
<>
{Boolean(itemUrls && itemUrls.length) && (
<div className="claim-grid__wrapper" key={list.name}>
<h1 className="claim-grid__header">
@ -124,7 +124,7 @@ export default function CollectionsListMine(props: Props) {
<ClaimList tileLayout key={list.name} uris={itemUrls.slice(0, 6)} collectionId={list.id} />
</div>
)}
</React.Fragment>
</>
);
})}
<div className="claim-grid__wrapper">

View file

@ -8,7 +8,6 @@ import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field';
import { SITE_NAME, ENABLE_COMMENT_REACTIONS } from 'config';
import React, { useEffect, useState } from 'react';
import { parseURI } from 'util/lbryURI';
import { formatNumber } from 'util/number';
import DateTime from 'component/dateTime';
import Button from 'component/button';
import Expandable from 'component/expandable';
@ -17,7 +16,7 @@ import CommentBadge from 'component/common/comment-badge'; // have this?
import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuButton } from '@reach/menu-button';
import Icon from 'component/common/icon';
import { FormFieldAreaAdvanced, Form } from 'component/common/form';
import { FormField, Form } from 'component/common/form';
import classnames from 'classnames';
import usePersistedState from 'effects/use-persisted-state';
import CommentReactions from 'component/commentReactions';
@ -319,7 +318,7 @@ function CommentView(props: Props) {
<div>
{isEditing ? (
<Form onSubmit={handleSubmit}>
<FormFieldAreaAdvanced
<FormField
className="comment__edit-input"
type={advancedEditor ? 'markdown' : 'textarea'}
name="editing_comment"
@ -385,7 +384,7 @@ function CommentView(props: Props) {
label={
numDirectReplies < 2
? __('Show reply')
: __('Show %count% replies', { count: formatNumber(numDirectReplies, 2, true) })
: __('Show %count% replies', { count: numDirectReplies })
}
button="link"
onClick={() => {

View file

@ -1,32 +0,0 @@
// @flow
import React from 'react';
import SelectChannel from 'component/selectChannel';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
type Props = {
isReply: boolean,
advancedHandler: () => void,
advanced: boolean,
};
export default function CommentCreateHeader(props: Props) {
const { isReply, advancedHandler, advanced } = props;
return (
<div className="comment-create__header">
<div className="comment-create__label-wrapper">
<span className="comment-create__label">{(isReply ? __('Replying as') : __('Comment as')) + ' '}</span>
<SelectChannel tiny />
</div>
<div className="form-field__quick-action">
<Button
button="alt"
icon={advanced ? ICONS.SIMPLE_EDITOR : ICONS.ADVANCED_EDITOR}
onClick={advancedHandler}
aria-label={isReply ? undefined : advanced ? __('Simple Editor') : __('Advanced Editor')}
/>
</div>
</div>
);
}

View file

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

View file

@ -9,7 +9,6 @@ import Button from 'component/button';
import ChannelThumbnail from 'component/channelThumbnail';
import { useHistory } from 'react-router';
import { useIsMobile } from 'effects/use-screensize';
import { formatNumber } from 'util/number';
type Props = {
myReacts: Array<string>,
@ -110,11 +109,7 @@ export default function CommentReactions(props: Props) {
'comment__action--active': myReacts && myReacts.includes(REACTION_TYPES.LIKE),
})}
onClick={handleCommentLike}
label={
<span className="comment__reaction-count">
{formatNumber(getCountForReact(REACTION_TYPES.LIKE), 2, true)}
</span>
}
label={<span className="comment__reaction-count">{getCountForReact(REACTION_TYPES.LIKE)}</span>}
/>
<Button
title={__('Downvote')}
@ -124,11 +119,7 @@ export default function CommentReactions(props: Props) {
'comment__action--active': myReacts && myReacts.includes(REACTION_TYPES.DISLIKE),
})}
onClick={handleCommentDislike}
label={
<span className="comment__reaction-count">
{formatNumber(getCountForReact(REACTION_TYPES.DISLIKE), 2, true)}
</span>
}
label={<span className="comment__reaction-count">{getCountForReact(REACTION_TYPES.DISLIKE)}</span>}
/>
{!shouldHide && ENABLE_CREATOR_REACTIONS && (canCreatorReact || creatorLiked) && (

View file

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

View file

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

View file

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

View file

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

View file

@ -1,240 +0,0 @@
// @flow
import 'easymde/dist/easymde.min.css';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import MarkdownPreview from 'component/common/markdown-preview';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import SimpleMDE from 'react-simplemde-editor';
import TextareaWithSuggestions from 'component/textareaWithSuggestions';
import type { ElementRef, Node } from 'react';
type Props = {
autoFocus?: boolean,
blockWrap: boolean,
charCount?: number,
children?: React$Node,
disabled?: boolean,
helper?: string | React$Node,
hideSuggestions?: boolean,
isLivestream?: boolean,
label?: string | Node,
labelOnLeft: boolean,
name: string,
noEmojis?: boolean,
placeholder?: string | number,
quickActionLabel?: string,
textAreaMaxLength?: number,
type?: string,
value?: string | number,
onChange?: (any) => any,
openEmoteMenu?: () => void,
quickActionHandler?: (any) => any,
render?: () => React$Node,
header?: React$Node,
};
export class FormFieldAreaAdvanced extends React.PureComponent<Props> {
static defaultProps = { labelOnLeft: false, blockWrap: true };
input: { current: ElementRef<any> };
constructor(props: Props) {
super(props);
this.input = React.createRef();
}
componentDidMount() {
const { autoFocus } = this.props;
const input = this.input.current;
if (input && autoFocus) input.focus();
}
render() {
const {
autoFocus,
blockWrap,
charCount,
children,
helper,
hideSuggestions,
isLivestream,
label,
header,
labelOnLeft,
name,
noEmojis,
quickActionLabel,
textAreaMaxLength,
type,
openEmoteMenu,
quickActionHandler,
render,
...inputProps
} = this.props;
// Ideally, the character count should (and can) be appended to the
// SimpleMDE's "options::status" bar. However, I couldn't figure out how
// to pass the current value to it's callback, nor query the current
// text length from the callback. So, we'll use our own widget.
const hasCharCount = charCount !== undefined && charCount >= 0;
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
);
const quickAction =
quickActionLabel && quickActionHandler ? (
<div className="form-field__quick-action">
<Button button="link" onClick={quickActionHandler} label={quickActionLabel} />
</div>
) : null;
const input = () => {
switch (type) {
case 'markdown':
const handleEvents = { contextmenu: openEditorMenu };
const getInstance = (editor) => {
// SimpleMDE max char check
editor.codemirror.on('beforeChange', (instance, changes) => {
if (textAreaMaxLength && changes.update) {
var str = changes.text.join('\n');
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from));
if (delta <= 0) return;
delta = instance.getValue().length + delta - textAreaMaxLength;
if (delta > 0) {
str = str.substring(0, str.length - delta);
changes.update(changes.from, changes.to, str.split('\n'));
}
}
});
// "Create Link (Ctrl-K)": highlight URL instead of label:
editor.codemirror.on('changes', (instance, changes) => {
try {
// Grab the last change from the buffered list. I assume the
// buffered one ('changes', instead of 'change') is more efficient,
// and that "Create Link" will always end up last in the list.
const lastChange = changes[changes.length - 1];
if (lastChange.origin === '+input') {
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765
const EASYMDE_URL_PLACEHOLDER = '(https://)';
// The URL placeholder is always placed last, so just look at the
// last text in the array to also cover the multi-line case:
const urlLineText = lastChange.text[lastChange.text.length - 1];
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) {
const from = lastChange.from;
const to = lastChange.to;
const isSelectionMultiline = lastChange.text.length > 1;
const baseIndex = isSelectionMultiline ? 0 : from.ch;
// Everything works fine for the [Ctrl-K] case, but for the
// [Button] case, this handler happens before the original
// code, thus our change got wiped out.
// Add a small delay to handle that case.
setTimeout(() => {
instance.setSelection(
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
);
}, 25);
}
}
} catch (e) {} // Do nothing (revert to original behavior)
});
};
return (
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
<fieldset-section>
{!header && (
<div className="form-field__two-column">
<div>
<label htmlFor={name}>{label}</label>
</div>
{quickAction}
</div>
)}
{!!header && <div className="form-field__textarea-header">{header}</div>}
<SimpleMDE
{...inputProps}
id={name}
type="textarea"
events={handleEvents}
getMdeInstance={getInstance}
options={{
spellChecker: true,
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
previewRender(plainText) {
const preview = <MarkdownPreview content={plainText} noDataStore />;
return ReactDOMServer.renderToString(preview);
},
}}
/>
{countInfo}
</fieldset-section>
</div>
);
case 'textarea':
return (
<fieldset-section>
{!header && (label || quickAction) && (
<div className="form-field__two-column">
<label htmlFor={name}>{label}</label>
{quickAction}
</div>
)}
{!!header && <div className="form-field__textarea-header">{header}</div>}
{hideSuggestions ? (
<textarea
type={type}
id={name}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
ref={this.input}
{...inputProps}
/>
) : (
<TextareaWithSuggestions
type={type}
id={name}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
inputRef={this.input}
isLivestream={isLivestream}
{...inputProps}
/>
)}
<div className="form-field__textarea-info">
{!noEmojis && openEmoteMenu && (
<Button
type="alt"
className="button--comment-icons"
title="Emotes"
onClick={openEmoteMenu}
icon={ICONS.EMOJI}
iconSize={20}
/>
)}
{countInfo}
</div>
</fieldset-section>
);
}
};
return (
<>
{type && input()}
{helper && <div className="form-field__help">{helper}</div>}
</>
);
}
}
export default FormFieldAreaAdvanced;

View file

@ -1,7 +1,14 @@
// @flow
import 'easymde/dist/easymde.min.css';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import * as ICONS from 'constants/icons';
import Button from 'component/button';
import MarkdownPreview from 'component/common/markdown-preview';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import SimpleMDE from 'react-simplemde-editor';
import TextareaWithSuggestions from 'component/textareaWithSuggestions';
import type { ElementRef, Node } from 'react';
type Props = {
@ -14,15 +21,19 @@ type Props = {
disabled?: boolean,
error?: string | boolean,
helper?: string | React$Node,
hideSuggestions?: boolean,
inputButton?: React$Node,
isLivestream?: boolean,
label?: string | Node,
labelOnLeft: boolean,
max?: number,
min?: number,
name: string,
noEmojis?: boolean,
placeholder?: string | number,
postfix?: string,
prefix?: string,
quickActionLabel?: string,
range?: number,
readOnly?: boolean,
stretch?: boolean,
@ -30,6 +41,8 @@ type Props = {
type?: string,
value?: string | number,
onChange?: (any) => any,
openEmoteMenu?: () => void,
quickActionHandler?: (any) => any,
render?: () => React$Node,
};
@ -59,15 +72,21 @@ export class FormField extends React.PureComponent<Props> {
children,
error,
helper,
hideSuggestions,
inputButton,
isLivestream,
label,
labelOnLeft,
name,
noEmojis,
postfix,
prefix,
quickActionLabel,
stretch,
textAreaMaxLength,
type,
openEmoteMenu,
quickActionHandler,
render,
...inputProps
} = this.props;
@ -82,10 +101,18 @@ export class FormField extends React.PureComponent<Props> {
const countInfo = hasCharCount && textAreaMaxLength !== undefined && (
<span className="comment__char-count-mde">{`${charCount || '0'}/${textAreaMaxLength}`}</span>
);
const Wrapper = blockWrap
? ({ children: innerChildren }) => <fieldset-section class="radio">{innerChildren}</fieldset-section>
: ({ children: innerChildren }) => <span className="radio">{innerChildren}</span>;
const quickAction =
quickActionLabel && quickActionHandler ? (
<div className="form-field__quick-action">
<Button button="link" onClick={quickActionHandler} label={quickActionLabel} />
</div>
) : null;
const inputSimple = (type: string) => (
<>
<input id={name} type={type} {...inputProps} />
@ -116,14 +143,102 @@ export class FormField extends React.PureComponent<Props> {
return inputSelect('');
case 'select-tiny':
return inputSelect('select--slim');
case 'markdown':
const handleEvents = { contextmenu: openEditorMenu };
const getInstance = (editor) => {
// SimpleMDE max char check
editor.codemirror.on('beforeChange', (instance, changes) => {
if (textAreaMaxLength && changes.update) {
var str = changes.text.join('\n');
var delta = str.length - (instance.indexFromPos(changes.to) - instance.indexFromPos(changes.from));
if (delta <= 0) return;
delta = instance.getValue().length + delta - textAreaMaxLength;
if (delta > 0) {
str = str.substring(0, str.length - delta);
changes.update(changes.from, changes.to, str.split('\n'));
}
}
});
// "Create Link (Ctrl-K)": highlight URL instead of label:
editor.codemirror.on('changes', (instance, changes) => {
try {
// Grab the last change from the buffered list. I assume the
// buffered one ('changes', instead of 'change') is more efficient,
// and that "Create Link" will always end up last in the list.
const lastChange = changes[changes.length - 1];
if (lastChange.origin === '+input') {
// https://github.com/Ionaru/easy-markdown-editor/blob/8fa54c496f98621d5f45f57577ce630bee8c41ee/src/js/easymde.js#L765
const EASYMDE_URL_PLACEHOLDER = '(https://)';
// The URL placeholder is always placed last, so just look at the
// last text in the array to also cover the multi-line case:
const urlLineText = lastChange.text[lastChange.text.length - 1];
if (urlLineText.endsWith(EASYMDE_URL_PLACEHOLDER) && urlLineText !== '[]' + EASYMDE_URL_PLACEHOLDER) {
const from = lastChange.from;
const to = lastChange.to;
const isSelectionMultiline = lastChange.text.length > 1;
const baseIndex = isSelectionMultiline ? 0 : from.ch;
// Everything works fine for the [Ctrl-K] case, but for the
// [Button] case, this handler happens before the original
// code, thus our change got wiped out.
// Add a small delay to handle that case.
setTimeout(() => {
instance.setSelection(
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf('(') + 1 },
{ line: to.line, ch: baseIndex + urlLineText.lastIndexOf(')') }
);
}, 25);
}
}
} catch (e) {} // Do nothing (revert to original behavior)
});
};
return (
<div className="form-field--SimpleMDE" onContextMenu={stopContextMenu}>
<fieldset-section>
<div className="form-field__two-column">
<div>
<label htmlFor={name}>{label}</label>
</div>
{quickAction}
</div>
<SimpleMDE
{...inputProps}
id={name}
type="textarea"
events={handleEvents}
getMdeInstance={getInstance}
options={{
spellChecker: true,
hideIcons: ['heading', 'image', 'fullscreen', 'side-by-side'],
previewRender(plainText) {
const preview = <MarkdownPreview content={plainText} noDataStore />;
return ReactDOMServer.renderToString(preview);
},
}}
/>
{countInfo}
</fieldset-section>
</div>
);
case 'textarea':
return (
<fieldset-section>
{label && (
{(label || quickAction) && (
<div className="form-field__two-column">
<label htmlFor={name}>{label}</label>
{quickAction}
</div>
)}
{hideSuggestions ? (
<textarea
type={type}
id={name}
@ -131,7 +246,30 @@ export class FormField extends React.PureComponent<Props> {
ref={this.input}
{...inputProps}
/>
<div className="form-field__textarea-info">{countInfo}</div>
) : (
<TextareaWithSuggestions
type={type}
id={name}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
inputRef={this.input}
isLivestream={isLivestream}
{...inputProps}
/>
)}
<div className="form-field__textarea-info">
{!noEmojis && openEmoteMenu && (
<Button
type="alt"
className="button--file-action"
title="Emotes"
onClick={openEmoteMenu}
icon={ICONS.EMOJI}
iconSize={20}
/>
)}
{countInfo}
</div>
</fieldset-section>
);
default:

View file

@ -1,5 +1,4 @@
export { Form } from './form-components/form';
export { FormField } from './form-components/form-field';
export { FormFieldAreaAdvanced } from './form-components/form-field-area-advanced';
export { FormFieldPrice } from './form-components/form-field-price';
export { Submit } from './form-components/submit';

View file

@ -2054,15 +2054,28 @@ export const icons = {
<path d="M12.5,23.24v-1A10.74,10.74,0,0,1,23.24,11.52" />
</g>
),
[ICONS.SIMPLE_EDITOR]: buildIcon(
[ICONS.HOST]: 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" />
<path d="M9 22.5 12 9l3 13.5" />
<circle cx={12} cy={7.657} r={1.25} />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 7.657c0-2.21.895-4.21 2.343-5.657l1.06 1.06A6.48 6.48 0 0 0 5.5 7.658a6.48 6.48 0 0 0 1.904 4.596l-1.06 1.06A7.975 7.975 0 0 1 4 7.658Zm13.657 5.657A7.975 7.975 0 0 0 20 7.657c0-2.21-.895-4.21-2.343-5.657l-1.06 1.06A6.48 6.48 0 0 1 18.5 7.658a6.48 6.48 0 0 1-1.904 4.596l1.06 1.06Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 7.657c0-1.38.56-2.63 1.464-3.536l1.061 1.061A3.49 3.49 0 0 0 8.5 7.657c0 .966.392 1.841 1.025 2.475l-1.06 1.06A4.984 4.984 0 0 1 7 7.657Zm8.536 3.535A4.984 4.984 0 0 0 17 7.657c0-1.38-.56-2.63-1.464-3.536l-1.061 1.061A3.49 3.49 0 0 1 15.5 7.657a3.49 3.49 0 0 1-1.025 2.475l1.06 1.06Z"
/>
<path d="M2 22h20" />
</g>
),
[ICONS.ADVANCED_EDITOR]: buildIcon(
[ICONS.NO_HOST]: 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" />
<path d="M9 22.5 12 9l3 13.5" />
<circle cx={12} cy={7.657} r={1.25} />
<path d="M2 22h20" />
</g>
),
};

View file

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

View file

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

View file

@ -11,10 +11,10 @@ import Icon from 'component/common/icon';
type Props = {
modal: { id: string, modalProps: {} },
filePath: ?string,
filePath: string | WebFile,
clearPublish: () => void,
updatePublishForm: ({}) => void,
openModal: (id: string, { files: Array<File> }) => void,
openModal: (id: string, { files: Array<WebFile> }) => void,
// React router
history: {
entities: {}[],
@ -37,7 +37,7 @@ function FileDrop(props: Props) {
const { drag, dropData } = useDragDrop();
const [files, setFiles] = React.useState([]);
const [error, setError] = React.useState(false);
const [target, setTarget] = React.useState<?File>(null);
const [target, setTarget] = React.useState<?WebFile>(null);
const hideTimer = React.useRef(null);
const targetTimer = React.useRef(null);
const navigationTimer = React.useRef(null);
@ -65,26 +65,24 @@ function FileDrop(props: Props) {
}
}, [history]);
// Handle file selection
const handleFileSelected = React.useCallback(
(selectedFile) => {
// Delay hide and navigation for a smooth transition
const hideDropArea = React.useCallback(() => {
hideTimer.current = setTimeout(() => {
setFiles([]);
// Navigate to publish area
navigationTimer.current = setTimeout(() => {
// Navigate first, THEN assign filePath, otherwise
// the file selected will get reset (that's how the
// publish file view works, when the user switches to
// publish a file, the pathFile value gets reset to undefined)
navigateToPublish();
updatePublishForm({
filePath: selectedFile.path || selectedFile.name,
});
}, NAVIGATE_TIME_OUT);
}, HIDE_TIME_OUT);
}, [navigateToPublish]);
// Handle file selection
const handleFileSelected = React.useCallback(
(selectedFile) => {
updatePublishForm({ filePath: selectedFile });
hideDropArea();
},
[setFiles, navigateToPublish, updatePublishForm]
[updatePublishForm, hideDropArea]
);
// Clear timers when unmounted

View file

@ -4,7 +4,7 @@ import * as ICONS from 'constants/icons';
import React from 'react';
import classnames from 'classnames';
import Button from 'component/button';
import { formatNumber } from 'util/number';
import { formatNumberWithCommas } from 'util/number';
import NudgeFloating from 'component/nudgeFloating';
type Props = {
claim: StreamClaim,
@ -18,8 +18,16 @@ type Props = {
};
function FileReactions(props: Props) {
const { claim, uri, doFetchReactions, doReactionLike, doReactionDislike, myReaction, likeCount, dislikeCount } =
props;
const {
claim,
uri,
doFetchReactions,
doReactionLike,
doReactionDislike,
myReaction,
likeCount,
dislikeCount,
} = props;
const claimId = claim && claim.claim_id;
const channel = claim && claim.signing_channel && claim.signing_channel.name;
@ -51,7 +59,7 @@ function FileReactions(props: Props) {
className={classnames('button--file-action button-like', {
'button--file-action-active': myReaction === REACTION_TYPES.LIKE,
})}
label={<>{formatNumber(likeCount, 2, true)}</>}
label={<>{formatNumberWithCommas(likeCount, 0)}</>}
iconSize={18}
icon={likeIcon}
onClick={() => doReactionLike(uri)}
@ -62,7 +70,7 @@ function FileReactions(props: Props) {
className={classnames('button--file-action button-dislike', {
'button--file-action-active': myReaction === REACTION_TYPES.DISLIKE,
})}
label={<>{formatNumber(dislikeCount, 2, true)}</>}
label={<>{formatNumberWithCommas(dislikeCount, 0)}</>}
iconSize={18}
icon={dislikeIcon}
onClick={() => doReactionDislike(uri)}

View file

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

View file

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

View file

@ -1,25 +1,16 @@
import { connect } from 'react-redux';
import { doResolveUri } from 'redux/actions/claims';
import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { selectPrimaryUri, makeSelectContentWatchedPercentageForUri } from 'redux/selectors/content';
import { makeSelectContentWatchedPercentageForUri } from 'redux/selectors/content';
import CardMedia from './view';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import * as SETTINGS from 'constants/settings';
const select = (state, props) => {
const claimUriBeingPlayed = selectPrimaryUri(state);
let isBeingPlayed = false;
if (claimUriBeingPlayed) {
const claim = makeSelectClaimForUri(props.uri)(state);
const claimBeingPlayed = makeSelectClaimForUri(claimUriBeingPlayed)(state);
isBeingPlayed = claim && claim.claim_id === claimBeingPlayed.claim_id;
}
return {
watchedPercentage: makeSelectContentWatchedPercentageForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
showPercentage: !isBeingPlayed && makeSelectClientSetting(SETTINGS.PERSIST_WATCH_TIME)(state),
showPercentage: makeSelectClientSetting(SETTINGS.PERSIST_WATCH_TIME)(state),
};
};

View file

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

View file

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

View file

@ -2,7 +2,7 @@
import React from 'react';
import HelpLink from 'component/common/help-link';
import Tooltip from 'component/common/tooltip';
import { formatNumber } from 'util/number';
import { toCompactNotation } from 'util/string';
type Props = {
claimId: ?string, // this
@ -14,8 +14,8 @@ type Props = {
function FileViewCount(props: Props) {
const { claimId, uri, fetchViewCount, viewCount } = props; // claimId
const countCompact = formatNumber(Number(viewCount), 2, true);
const countFullResolution = formatNumber(Number(viewCount), 2, false);
const countCompact = toCompactNotation(viewCount);
const countFullResolution = Number(viewCount).toLocaleString();
React.useEffect(() => {
if (claimId) {
@ -24,7 +24,7 @@ function FileViewCount(props: Props) {
}, [fetchViewCount, uri, claimId]);
return (
<Tooltip title={`${countFullResolution}`}>
<Tooltip label={countFullResolution}>
<span className="media__subtitle--centered">
{viewCount !== 1 ? __('%view_count% views', { view_count: countCompact }) : __('1 view')}
{<HelpLink href="https://lbry.com/faq/views" />}

View file

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

View file

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

View file

@ -1,7 +1,4 @@
// @flow
// import 'scss/component/_header.scss'; // ody codesplits this; no.
import { ENABLE_UI_NOTIFICATIONS } from 'config';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
@ -12,16 +9,14 @@ import React from 'react';
import Tooltip from 'component/common/tooltip';
type HeaderMenuButtonProps = {
authenticated: boolean,
automaticDarkModeEnabled: boolean,
currentTheme: string,
user: ?User,
handleThemeToggle: (boolean, string) => void,
};
export default function HeaderMenuButtons(props: HeaderMenuButtonProps) {
const { automaticDarkModeEnabled, currentTheme, user, handleThemeToggle } = props;
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
const { automaticDarkModeEnabled, currentTheme, handleThemeToggle } = props;
return (
<div className="header__buttons">
@ -38,7 +33,7 @@ export default function HeaderMenuButtons(props: HeaderMenuButtonProps) {
</MenuList>
</Menu>
{notificationsEnabled && <NotificationHeaderButton />}
<NotificationHeaderButton />
<Menu>
<Tooltip title={__('Settings')}>

View file

@ -1,14 +1,16 @@
import { connect } from 'react-redux';
import { selectUnseenNotificationCount } from 'redux/selectors/notifications';
import { doSeeAllNotifications } from 'redux/actions/notifications';
import { selectUnseenNotificationCount, selectUnseenLocalNotificationCount } from 'redux/selectors/notifications';
import { doLbryioSeeAllNotifications, doLocalSeeAllNotifications } from 'redux/actions/notifications';
import { selectUser } from 'redux/selectors/user';
import NotificationHeaderButton from './view';
const select = (state) => ({
unseenCount: selectUnseenNotificationCount(state),
unseenLocalCount: selectUnseenLocalNotificationCount(state),
user: selectUser(state),
});
export default connect(select, {
doSeeAllNotifications,
doLbryioSeeAllNotifications,
doLocalSeeAllNotifications,
})(NotificationHeaderButton);

View file

@ -1,7 +1,4 @@
// @flow
// import 'scss/component/_header.scss'; // ody codesplits this; no. REMOVE THESE
import { ENABLE_UI_NOTIFICATIONS } from 'config';
import { useHistory } from 'react-router';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
@ -13,23 +10,22 @@ import Tooltip from 'component/common/tooltip';
type Props = {
unseenCount: number,
user: ?User,
doSeeAllNotifications: () => void,
unseenLocalCount: number,
doLbryioSeeAllNotifications: () => void,
doLocalSeeAllNotifications: () => void,
};
export default function NotificationHeaderButton(props: Props) {
const { unseenCount, user, doSeeAllNotifications } = props;
const { unseenCount, unseenLocalCount, doLbryioSeeAllNotifications, doLocalSeeAllNotifications } = props;
const { push } = useHistory();
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
function handleMenuClick() {
if (unseenCount > 0) doSeeAllNotifications();
if (unseenCount > 0) doLbryioSeeAllNotifications();
if (unseenLocalCount > 0) doLocalSeeAllNotifications();
push(`/$/${PAGES.NOTIFICATIONS}`);
}
if (!notificationsEnabled) return null;
return (
<Tooltip title={__('Notifications')}>
<Button onClick={handleMenuClick} className="header__navigationItem--icon">

View file

@ -1,27 +0,0 @@
import { connect } from 'react-redux';
import HostingSplash from './view';
import {
selectViewBlobSpace,
selectViewHostingLimit,
selectAutoBlobSpace,
selectAutoHostingLimit,
selectSaveBlobs,
} from 'redux/selectors/settings';
import { doSetDaemonSetting } from 'redux/actions/settings';
import { selectDiskSpace } from 'redux/selectors/app';
const select = (state) => ({
diskSpace: selectDiskSpace(state),
viewHostingLimit: selectViewHostingLimit(state),
autoHostingLimit: selectAutoHostingLimit(state),
viewBlobSpace: selectViewBlobSpace(state),
autoBlobSpace: selectAutoBlobSpace(state),
saveBlobs: selectSaveBlobs(state),
});
const perform = (dispatch) => ({
setDaemonSetting: (key, value) => dispatch(doSetDaemonSetting(key, value)),
});
export default connect(select, perform)(HostingSplash);

View file

@ -1,156 +0,0 @@
// @flow
import React from 'react';
import Button from 'component/button';
import { FormField } from 'component/common/form-components/form-field';
import { Form } from 'component/common/form-components/form';
import { withRouter } from 'react-router-dom';
// $FlowFixMe cannot resolve ...
import image from 'static/img/yrblhappy.svg';
import * as DAEMON_SETTINGS from 'constants/daemon_settings';
type SetDaemonSettingArg = boolean | string | number;
type Props = {
handleNextPage: () => void,
handleDone: () => void,
setDaemonSetting: (string, ?SetDaemonSettingArg) => void,
// --- select ---
diskSpace: DiskSpace, // KB
viewHostingLimit: number, // MB
autoHostingLimit: number,
viewBlobSpace: number,
autoBlobSpace: number,
privateBlobSpace: number,
saveBlobs: boolean,
};
const TWENTY_PERCENT = 0.2;
const TEN_PRECNT = 0.1;
function HostingSplash(props: Props) {
const {
handleNextPage,
diskSpace,
viewHostingLimit,
autoHostingLimit,
viewBlobSpace,
autoBlobSpace,
saveBlobs,
setDaemonSetting,
handleDone,
} = props;
const totalMB = diskSpace && Math.floor(diskSpace.total / 1024);
const freeMB = diskSpace && Math.floor(diskSpace.free / 1024);
const blobSpaceUsed = viewBlobSpace + autoBlobSpace;
const [hostingChoice, setHostingChoice] = React.useState('MANAGED');
function handleSubmit() {
if (hostingChoice === 'CUSTOM') {
handleNextPage();
} else {
handleAuto();
}
}
function getManagedLimitMB() {
const value =
freeMB > totalMB * TWENTY_PERCENT // lots of free space?
? blobSpaceUsed > totalMB * TEN_PRECNT // using more than 10%?
? (freeMB + blobSpaceUsed) / 2 // e.g. 40g used plus 30g free, knock back to 35g limit, freeing to 35g
: totalMB * TEN_PRECNT // let it go up to 10%
: (freeMB + blobSpaceUsed) / 2; // e.g. 40g used plus 10g free, knock back to 25g limit, freeing to 25g
return value > 10240 ? Math.floor(value / 1024) * 1024 : 0;
}
function getAutoLimit() {
// return floor of 10% of total
const totalGB = Math.floor(getManagedLimitMB() / 1024); // eg, 25GB
return Math.floor(totalGB / 10) * 1024; // eg, 2 GB -> 2048MB
}
function getViewedLimit() {
return getManagedLimitMB() - getAutoLimit();
}
function getManagedCopy() {
if (viewHostingLimit || autoHostingLimit || !saveBlobs) {
return __("I'm happy with my settings");
} else if (getManagedLimitMB() > 0) {
return __(`Host up to %percent% of my drive (%limit% GB)`, {
percent: `${Math.round((Math.floor(getManagedLimitMB() / 1024) / Math.floor(totalMB / 1024)) * 100)}%`,
limit: Math.floor(getManagedLimitMB() / 1024),
});
} else {
return __(`Not now, my disk is almost full.`);
}
}
function getManagedHelper() {
if (viewHostingLimit || autoHostingLimit || !saveBlobs) {
return __(`We've noticed you already have some settings.`);
} else if (getManagedLimitMB() > 0) {
return __(`Donate space without filling up your drive.`);
} else {
return __(`You can clear some space and check hosting settings later.`);
}
}
async function handleAuto() {
if (viewHostingLimit || autoHostingLimit || !saveBlobs) {
handleDone();
} else if (getManagedLimitMB() > 0) {
// limit to used // maybe move this to a single action function that doesn't live inside the component.
await setDaemonSetting(DAEMON_SETTINGS.BLOB_STORAGE_LIMIT_MB, getViewedLimit());
await setDaemonSetting(DAEMON_SETTINGS.NETWORK_STORAGE_LIMIT_MB, getAutoLimit());
handleDone();
} else {
// running low on space
handleDone();
}
}
return (
<section className="main--contained">
<div className={'columns first-run__wrapper'}>
<div className={'first-run__left'}>
<div>
<h1 className="section__title--large">{__('Hosting')}</h1>
<h3 className="section__subtitle">
{__('Help creators and improve the P2P data network by hosting content.')}
</h3>
<fieldset>
<FormField
name={'managedhosting'}
type="radio"
checked={hostingChoice === 'MANAGED'}
label={getManagedCopy()}
helper={getManagedHelper()}
onChange={(e) => setHostingChoice('MANAGED')}
/>
<FormField
name={'customhosting'}
type="radio"
checked={hostingChoice === 'CUSTOM'}
label={<>{__('Custom')}</>}
helper={__(`You choose how much data to host.`)}
onChange={(e) => setHostingChoice('CUSTOM')}
/>
</fieldset>
</div>
<Form onSubmit={handleSubmit} className="section__body">
<div className={'card__actions'}>
<Button button="primary" label={hostingChoice === 'CUSTOM' ? __('Next') : __(`Let's go`)} type="submit" />
</div>
</Form>
</div>
<div className={'first-run__image-wrapper'}>
<img src={image} className="privacy-img" />
</div>
</div>
</section>
);
}
export default withRouter(HostingSplash);

View file

@ -1,3 +0,0 @@
import HostingSplashCustom from './view';
export default HostingSplashCustom;

View file

@ -1,31 +0,0 @@
// @flow
import React from 'react';
import Button from 'component/button';
import { Form } from 'component/common/form-components/form';
import SettingStorage from 'component/settingStorage';
import { withRouter } from 'react-router-dom';
type Props = {
handleNextPage: () => void,
handleGoBack: () => void,
};
function HostingSplashCustom(props: Props) {
const { handleNextPage, handleGoBack } = props;
return (
<section className="main--contained">
<div className={'first-run__wrapper'}>
<SettingStorage isWelcome />
<Form onSubmit={handleNextPage} className="section__body">
<div className={'card__actions'}>
<Button button="primary" label={__(`Let's go`)} type="submit" />
<Button button="link" label={__(`Go back`)} onClick={handleGoBack} />
</div>
</Form>
</div>
</section>
);
}
export default withRouter(HostingSplashCustom);

View file

@ -0,0 +1,14 @@
import { connect } from 'react-redux';
import { selectReferralReward } from 'redux/selectors/rewards';
import { selectUserInvitees, selectUserInviteStatusIsPending } from 'redux/selectors/user';
import InviteList from './view';
const select = state => ({
invitees: selectUserInvitees(state),
isPending: selectUserInviteStatusIsPending(state),
referralReward: selectReferralReward(state),
});
const perform = () => ({});
export default connect(select, perform)(InviteList);

View file

@ -0,0 +1,99 @@
// @flow
import React from 'react';
import RewardLink from 'component/rewardLink';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
import Card from 'component/common/card';
import LbcMessage from 'component/common/lbc-message';
type Props = {
invitees: ?Array<{
email: string,
invite_accepted: boolean,
invite_reward_claimed: boolean,
invite_reward_claimable: boolean,
}>,
referralReward: ?Reward,
};
class InviteList extends React.PureComponent<Props> {
render() {
const { invitees, referralReward } = this.props;
if (!invitees || !invitees.length) {
return null;
}
let rewardAmount = 0;
let rewardHelp = __(
"Woah, you have a lot of friends! You've claimed the maximum amount of invite rewards. Email %email% if you'd like to be whitelisted for more invites.",
{ email: 'hello@lbry.com' }
);
if (referralReward) {
rewardAmount = referralReward.reward_amount;
rewardHelp = referralReward.reward_description;
}
const showClaimable = invitees.some(invite => invite.invite_reward_claimable && !invite.invite_reward_claimed);
return (
<Card
title={<div className="table__header-text">{__('Invite History')}</div>}
subtitle={
<div className="table__header-text">
<LbcMessage>{rewardHelp}</LbcMessage>
</div>
}
titleActions={
referralReward &&
showClaimable && (
<div className="card__actions--inline">
<RewardLink
button
label={__(`Claim Your %reward_amount% Credit Invite Reward`, { reward_amount: rewardAmount })}
claim_code={referralReward.claim_code}
/>
</div>
)
}
isBodyList
body={
<div className="table__wrapper">
<table className="table section">
<thead>
<tr>
<th>{__('Invitee Email')}</th>
<th>{__('Invite Status')}</th>
<th>{__('Reward')}</th>
</tr>
</thead>
<tbody>
{invitees.map(invitee => (
<tr key={invitee.email}>
<td>{invitee.email}</td>
<td>
<span>{invitee.invite_accepted ? __('Accepted') : __('Not Accepted')}</span>
</td>
<td>
{invitee.invite_reward_claimed && (
<React.Fragment>
<span>{__('Claimed')}</span>
<Icon icon={ICONS.COMPLETE} />
</React.Fragment>
)}
{!invitee.invite_reward_claimed &&
(invitee.invite_reward_claimable ? <span>{__('Claimable')}</span> : __('Unclaimable'))}
</td>
</tr>
))}
</tbody>
</table>
</div>
}
/>
);
}
}
export default InviteList;

View file

@ -0,0 +1,29 @@
import { connect } from 'react-redux';
import {
selectUserInvitesRemaining,
selectUserInviteNewIsPending,
selectUserInviteNewErrorMessage,
selectUserInviteReferralLink,
selectUserInviteReferralCode,
} from 'redux/selectors/user';
// import { doUserInviteNew } from 'redux/actions/user';
import { selectMyChannelClaims, selectFetchingMyChannels } from 'redux/selectors/claims';
import { doFetchChannelListMine } from 'redux/actions/claims';
import InviteNew from './view';
const select = (state) => ({
errorMessage: selectUserInviteNewErrorMessage(state),
invitesRemaining: selectUserInvitesRemaining(state),
referralLink: selectUserInviteReferralLink(state),
referralCode: selectUserInviteReferralCode(state),
isPending: selectUserInviteNewIsPending(state),
channels: selectMyChannelClaims(state),
fetchingChannels: selectFetchingMyChannels(state),
});
const perform = (dispatch) => ({
// inviteNew: (email) => dispatch(doUserInviteNew(email)),
fetchChannelListMine: () => dispatch(doFetchChannelListMine()),
});
export default connect(select, perform)(InviteNew);

View file

@ -0,0 +1,154 @@
// @flow
import { URL, SITE_NAME } from 'config';
import React, { useEffect, useState } from 'react';
import Button from 'component/button';
import { Form, FormField } from 'component/common/form';
import CopyableText from 'component/copyableText';
import Card from 'component/common/card';
import analytics from 'analytics';
import I18nMessage from 'component/i18nMessage';
import LbcSymbol from 'component/common/lbc-symbol';
type Props = {
errorMessage: ?string,
inviteNew: (string) => void,
isPending: boolean,
referralLink: string,
referralCode: string,
channels: ?Array<ChannelClaim>,
};
function InviteNew(props: Props) {
const { inviteNew, errorMessage, isPending, referralCode = '', channels } = props;
// Email
const [email, setEmail] = useState('');
function handleSubmit() {
inviteNew(email);
}
function handleEmailChanged(event: any) {
setEmail(event.target.value);
}
// Referral link
const [referralSource, setReferralSource] = useState(referralCode);
const handleReferralChange = React.useCallback(
(code) => {
setReferralSource(code);
// TODO: keep track of this in an array?
const matchingChannel = channels && channels.find((ch) => ch.name === code);
if (matchingChannel) {
analytics.apiLogPublish(matchingChannel);
}
},
[setReferralSource]
);
const topChannel =
channels &&
channels.reduce((top, channel) => {
const topClaimCount = (top && top.meta && top.meta.claims_in_channel) || 0;
const currentClaimCount = (channel && channel.meta && channel.meta.claims_in_channel) || 0;
return topClaimCount >= currentClaimCount ? top : channel;
});
const referralString =
channels && channels.length && referralSource !== referralCode
? lookupUrlByClaimName(referralSource, channels)
: referralSource;
const referral = `${URL}/$/invite/${referralString.replace('#', ':')}`;
useEffect(() => {
// set default channel
if (topChannel) {
handleReferralChange(topChannel.name);
}
}, [topChannel, handleReferralChange]);
function lookupUrlByClaimName(name, channels) {
const claim = channels.find((channel) => channel.name === name);
return claim && claim.canonical_url ? claim.canonical_url.replace('lbry://', '') : name;
}
return (
<div className={'columns'}>
<div className="column">
<Card
title={__('Invites')}
subtitle={
<I18nMessage tokens={{ SITE_NAME, lbc: <LbcSymbol /> }}>
Earn %lbc% for inviting subscribers, followers, fans, friends, etc. to join and follow you on %SITE_NAME%.
You can use invites just like affiliate links.
</I18nMessage>
}
actions={
<React.Fragment>
<CopyableText label={__('Your invite link')} copyable={referral} />
{channels && channels.length > 0 && (
<FormField
type="select"
label={__('Customize link')}
value={referralSource}
onChange={(e) => handleReferralChange(e.target.value)}
>
{channels.map((channel) => (
<option key={channel.claim_id} value={channel.name}>
{channel.name}
</option>
))}
<option value={referralCode}>{referralCode}</option>
</FormField>
)}
</React.Fragment>
}
/>
</div>
<div className="column">
<Card
title={__('Invite by email')}
subtitle={
<I18nMessage tokens={{ SITE_NAME, lbc: <LbcSymbol /> }}>
Invite someone you know by email and earn %lbc% when they join %SITE_NAME%.
</I18nMessage>
}
actions={
<React.Fragment>
<Form onSubmit={handleSubmit}>
<FormField
type="text"
label={__('Email')}
placeholder="youremail@example.org"
name="email"
value={email}
error={errorMessage}
inputButton={
<Button button="secondary" type="submit" label={__('Invite')} disabled={isPending || !email} />
}
onChange={(event) => {
handleEmailChanged(event);
}}
/>
<p className="help">
<I18nMessage
tokens={{
rewards_link: <Button button="link" navigate="/$/rewards" label={__('rewards')} />,
referral_faq_link: (
<Button button="link" label={__('FAQ')} href="https://lbry.com/faq/referrals" />
),
}}
>
Read our %referral_faq_link% to learn more about rewards.
</I18nMessage>
</p>
</Form>
</React.Fragment>
}
/>
</div>
</div>
);
}
export default InviteNew;

View file

@ -0,0 +1,30 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import REWARDS from 'rewards';
import { selectUser, selectSetReferrerPending, selectSetReferrerError } from 'redux/selectors/user';
import { doClaimRewardType } from 'redux/actions/rewards';
import { selectUnclaimedRewards } from 'redux/selectors/rewards';
import { doUserSetReferrer } from 'redux/actions/user';
import { selectIsSubscribedForUri } from 'redux/selectors/subscriptions';
import { doChannelSubscribe } from 'redux/actions/subscriptions';
import Invited from './view';
const select = (state, props) => {
return {
user: selectUser(state),
referrerSetPending: selectSetReferrerPending(state),
referrerSetError: selectSetReferrerError(state),
rewards: selectUnclaimedRewards(state),
isSubscribed: selectIsSubscribedForUri(state, props.fullUri),
fullUri: props.fullUri,
referrer: props.referrer,
};
};
const perform = (dispatch) => ({
claimReward: () => dispatch(doClaimRewardType(REWARDS.TYPE_REFEREE)),
setReferrer: (referrer) => dispatch(doUserSetReferrer(referrer)),
channelSubscribe: (uri) => dispatch(doChannelSubscribe(uri)),
});
export default withRouter(connect(select, perform)(Invited));

View file

@ -0,0 +1,225 @@
// @flow
import { SITE_NAME } from 'config';
import * as PAGES from 'constants/pages';
import React, { useEffect } from 'react';
import Button from 'component/button';
import ClaimPreview from 'component/claimPreview';
import Card from 'component/common/card';
import { buildURI, parseURI } from 'util/lbryURI';
import { ERRORS } from 'lbryinc';
import REWARDS from 'rewards';
import { formatLbryUrlForWeb } from 'util/url';
import ChannelContent from 'component/channelContent';
import I18nMessage from 'component/i18nMessage';
type Props = {
user: any,
claimReward: () => void,
setReferrer: (string) => void,
referrerSetPending: boolean,
referrerSetError: string,
channelSubscribe: (sub: Subscription) => void,
history: { push: (string) => void },
rewards: Array<Reward>,
referrer: string,
fullUri: string,
isSubscribed: boolean,
};
function Invited(props: Props) {
const {
user,
claimReward,
setReferrer,
referrerSetPending,
referrerSetError,
channelSubscribe,
history,
rewards,
fullUri,
referrer,
isSubscribed,
} = props;
const refUri = referrer && 'lbry://' + referrer.replace(':', '#');
const {
isChannel: referrerIsChannel,
claimName: referrerChannelName,
channelClaimId: referrerChannelClaimId,
} = parseURI(refUri);
const channelUri =
referrerIsChannel &&
formatLbryUrlForWeb(buildURI({ channelName: referrerChannelName, channelClaimId: referrerChannelClaimId }));
const rewardsApproved = user && user.is_reward_approved;
const hasVerifiedEmail = user && user.has_verified_email;
const referredRewardAvailable = rewards && rewards.some((reward) => reward.reward_type === REWARDS.TYPE_REFEREE);
const redirect = channelUri || `/`;
// always follow if it's a channel
useEffect(() => {
if (fullUri && !isSubscribed && fullUri) {
let channelName;
try {
const { claimName } = parseURI(fullUri);
channelName = claimName;
} catch (e) {}
if (channelName) {
channelSubscribe({
channelName: channelName,
uri: fullUri,
});
}
}
}, [fullUri, isSubscribed, channelSubscribe]);
useEffect(() => {
if (!referrerSetPending && hasVerifiedEmail) {
claimReward();
}
}, [referrerSetPending, hasVerifiedEmail, claimReward]);
useEffect(() => {
if (referrer) {
setReferrer(referrer.replace(':', '#'));
}
}, [referrer, setReferrer]);
function handleDone() {
history.push(redirect);
}
if (referrerSetError === ERRORS.ALREADY_CLAIMED) {
return (
<Card
title={__(`Whoa`)}
subtitle={
referrerIsChannel
? __(`You've already claimed your referrer, but we've followed this channel for you.`)
: __(`You've already claimed your referrer.`)
}
body={
referrerIsChannel && (
<div className="claim-preview--channel">
<ClaimPreview key={refUri} uri={refUri} actions={''} type={'small'} />
</div>
)
}
actions={
<div className="card__actions">
<Button button="primary" label={__('Done!')} onClick={handleDone} />
</div>
}
/>
);
}
if (referrerSetError && referredRewardAvailable) {
return (
<Card
title={__(`Welcome!`)}
subtitle={__(
`Something went wrong with your invite link. You can set and claim your invite reward after signing in.`
)}
actions={
<>
<p className="error__text">{__('Not a valid invite')}</p>
<div className="card__actions">
<Button
button="primary"
label={hasVerifiedEmail ? __('Verify') : __('Create Account')}
navigate={`/$/${PAGES.AUTH}?redirect=/$/${PAGES.REWARDS}`}
/>
<Button button="link" label={__('Explore')} onClick={handleDone} />
</div>
</>
}
/>
);
}
if (!rewardsApproved) {
const signUpButton = (
<Button
button="link"
label={hasVerifiedEmail ? __(`Finish verification `) : __(`Create an account `)}
navigate={`/$/${PAGES.AUTH}?redirect=/$/${PAGES.INVITE}/${referrer}`}
/>
);
return (
<Card
title={
referrerIsChannel
? __('%channel_name% invites you to the party!', { channel_name: referrerChannelName })
: __(`You're invited!`)
}
subtitle={
<p>
{referrerIsChannel ? (
<I18nMessage
tokens={{
channel_name: referrerChannelName,
signup_link: signUpButton,
SITE_NAME,
}}
>
%channel_name% is waiting for you on %SITE_NAME%. Create your account now.
</I18nMessage>
) : (
<I18nMessage
tokens={{
signup_link: signUpButton,
}}
>
Content freedom and a present are waiting for you. %signup_link% to claim it.
</I18nMessage>
)}
</p>
}
body={
referrerIsChannel && (
<div className="claim-preview--channel">
<div className="section">
<ClaimPreview key={refUri} uri={refUri} actions={''} type={'small'} />
</div>
<div className="section">
<ChannelContent uri={fullUri} defaultPageSize={3} defaultInfiniteScroll={false} />
</div>
</div>
)
}
actions={
<div className="section__actions">
<Button
button="primary"
label={hasVerifiedEmail ? __('Finish Account') : __('Create Account')}
navigate={`/$/${PAGES.AUTH}?redirect=/$/${PAGES.INVITE}/${referrer}`}
/>
<Button button="link" label={__('Skip')} onClick={handleDone} />
</div>
}
/>
);
}
return (
<Card
title={__(`Welcome!`)}
subtitle={referrerIsChannel ? __(`We've followed your invitee for you. Check them out!`) : __(`Congrats!`)}
body={
referrerIsChannel && (
<div className="claim-preview--channel">
<ClaimPreview key={refUri} uri={refUri} actions={''} type={'small'} />
</div>
)
}
actions={
<div className="section__actions">
<Button button="primary" label={__('Done')} onClick={handleDone} />
</div>
}
/>
);
}
export default Invited;

View file

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

View file

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

View file

@ -1,10 +1,15 @@
import { connect } from 'react-redux';
import { doReadNotifications, doDeleteNotification } from 'redux/actions/notifications';
import {
doLbryioNotificationsMarkRead,
doLbryioDeleteNotification,
doLocalDeleteNotification,
} from 'redux/actions/notifications';
import Notification from './view';
const perform = (dispatch, ownProps) => ({
readNotification: () => dispatch(doReadNotifications([ownProps.notification.id])),
deleteNotification: () => dispatch(doDeleteNotification(ownProps.notification.id)),
readNotification: () => dispatch(doLbryioNotificationsMarkRead([ownProps.notification.id])),
deleteNotification: () => dispatch(doLbryioDeleteNotification(ownProps.notification.id)),
deleteLocalNotification: () => dispatch(doLocalDeleteNotification(ownProps.notification.id)),
});
export default connect(null, perform)(Notification);

View file

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

View file

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

View file

@ -72,9 +72,9 @@ function PrivacyAgreement(props: Props) {
{__('No')} <span>😢</span>
</>
}
helper={__(
`* Note that as peer-to-peer software, your IP address and potentially other system information can be sent to other users, though this information is not stored permanently.`
)}
helper={__(`* Note that as
peer-to-peer software, your IP address and potentially other system information can be sent to other
users, though this information is not stored permanently.`)}
onChange={(e) => setShare(NONE)}
/>
{authenticated && (
@ -92,7 +92,7 @@ function PrivacyAgreement(props: Props) {
)}
</fieldset>
<div className={'card__actions'}>
<Button button="primary" label={__(`Next`)} disabled={!share} type="submit" />
<Button button="primary" label={__(`Let's go`)} disabled={!share} type="submit" />
</div>
{share === NONE && (
<p className="help">

View file

@ -1,6 +1,4 @@
// @flow
import { formatCredits } from 'util/format-credits';
type Props = {
uri: ?string,
isResolvingUri: boolean,
@ -25,7 +23,7 @@ function BidHelpText(props: Props) {
bidHelpText = __(
'If you bid more than %amount% LBRY Credits, when someone navigates to %uri%, it will load your published content. However, you can get a longer version of this URL for any bid.',
{
amount: formatCredits(amountNeededForTakeover, 2, true),
amount: amountNeededForTakeover,
uri: uri,
}
);

View file

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

View file

@ -2,7 +2,6 @@
import type { Node } from 'react';
import * as ICONS from 'constants/icons';
import React, { useState, useEffect } from 'react';
import { ipcRenderer } from 'electron';
import { regexInvalidURI } from 'util/lbryURI';
import PostEditor from 'component/postEditor';
import FileSelector from 'component/common/file-selector';
@ -14,13 +13,13 @@ import I18nMessage from 'component/i18nMessage';
import usePersistedState from 'effects/use-persisted-state';
import * as PUBLISH_MODES from 'constants/publish_types';
import PublishName from 'component/publishName';
import path from 'path';
type Props = {
uri: ?string,
mode: ?string,
name: ?string,
title: ?string,
filePath: ?string,
filePath: string | WebFile,
fileMimeType: ?string,
isStillEditing: boolean,
balance: number,
@ -78,7 +77,7 @@ function PublishFile(props: Props) {
const sizeInMB = Number(size) / 1000000;
const secondsToProcess = sizeInMB / PROCESSING_MB_PER_SECOND;
const ffmpegAvail = ffmpegStatus.available;
const currentFile = filePath;
const [currentFile, setCurrentFile] = useState(null);
const [currentFileType, setCurrentFileType] = useState(null);
const [optimizeAvail, setOptimizeAvail] = useState(false);
const [userOptimize, setUserOptimize] = usePersistedState('publish-file-user-optimize', false);
@ -92,35 +91,17 @@ function PublishFile(props: Props) {
}
}, [currentFileType, mode, isStillEditing, updatePublishForm]);
// Since the filePath can be updated from outside this component
// (for instance, when the user drags & drops a file), we need
// to check for changes in the selected file using an effect.
useEffect(() => {
if (!filePath) {
return;
if (!filePath || filePath === '') {
setCurrentFile('');
updateFileInfo(0, 0, false);
} else if (typeof filePath !== 'string') {
// Update currentFile file
if (filePath.name !== currentFile && filePath.path !== currentFile) {
handleFileChange(filePath);
}
async function readSelectedFileDetails() {
// Read the file to get the file's duration (if possible)
// and offer transcoding it.
const result = await ipcRenderer.invoke('get-file-details-from-path', filePath);
let file;
if (result.buffer) {
file = new File([result.buffer], result.name, {
type: result.mime,
});
}
const fileData: FileData = {
path: result.path,
name: result.name,
mimeType: result.mime || 'application/octet-stream',
size: result.size,
duration: result.duration,
file: file,
};
processSelectedFile(fileData);
}
readSelectedFileDetails();
}, [filePath]);
}, [filePath, currentFile, handleFileChange, updateFileInfo]);
useEffect(() => {
const isOptimizeAvail = currentFile && currentFile !== '' && isVid && ffmpegAvail;
@ -228,11 +209,11 @@ function PublishFile(props: Props) {
}
}
function processSelectedFile(fileData: FileData, clearName = true) {
function handleFileChange(file: WebFile, clearName = true) {
window.URL = window.URL || window.webkitURL;
// select file, start to select a new one, then cancel
if (!fileData || fileData.error) {
if (!file) {
if (isStillEditing || !clearName) {
updatePublishForm({ filePath: '' });
} else {
@ -241,12 +222,8 @@ function PublishFile(props: Props) {
return;
}
// if video, extract duration so we can warn about bitrate if (typeof file !== 'string')
const file = fileData.file;
// Check to see if it's a video and if mp4
const contentType = fileData.mimeType && fileData.mimeType.split('/'); // get this from electron side
const duration = fileData.duration;
const size = fileData.size;
// if video, extract duration so we can warn about bitrateif (typeof file !== 'string') {
const contentType = file.type && file.type.split('/');
const isVideo = contentType && contentType[0] === 'video';
const isMp4 = contentType && contentType[1] === 'mp4';
@ -254,25 +231,34 @@ function PublishFile(props: Props) {
if (contentType && contentType[0] === 'text') {
isTextPost = contentType[1] === 'plain' || contentType[1] === 'markdown';
setCurrentFileType(contentType.join('/'));
} else if (path.parse(fileData.path).ext) {
// If user's machine is missing a valid content type registration
setCurrentFileType(contentType);
} else if (file.name) {
// If user's machine is missign a valid content type registration
// for markdown content: text/markdown, file extension will be used instead
const extension = path.parse(fileData.path).ext;
const extension = file.name.split('.').pop();
isTextPost = MARKDOWN_FILE_EXTENSIONS.includes(extension);
}
if (isVideo) {
if (isMp4) {
updateFileInfo(duration || 0, size, isVideo);
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
updateFileInfo(video.duration, file.size, isVideo);
window.URL.revokeObjectURL(video.src);
};
video.onerror = () => {
updateFileInfo(0, file.size, isVideo);
};
video.src = window.URL.createObjectURL(file);
} else {
updateFileInfo(duration || 0, size, isVideo);
updateFileInfo(0, file.size, isVideo);
}
} else {
updateFileInfo(0, size, isVideo);
updateFileInfo(0, file.size, isVideo);
}
if (isTextPost && file) {
if (isTextPost) {
// Create reader
const reader = new FileReader();
// Handler for file reader
@ -284,17 +270,21 @@ function PublishFile(props: Props) {
setPublishMode(PUBLISH_MODES.FILE);
}
// Strip off extension and replace invalid characters
const publishFormParams: { filePath: string | WebFile, name?: string, optimize?: boolean } = {
// if electron, we'll set filePath to the path string because SDK is handling publishing.
// File.path will be undefined from web due to browser security, so it will default to the File Object.
filePath: file.path || file,
};
// Strip off extention and replace invalid characters
let fileName = name || (file.name && file.name.substring(0, file.name.lastIndexOf('.'))) || '';
if (!isStillEditing) {
const fileWithoutExtension = path.parse(fileData.path).name;
updatePublishForm({ name: parseName(fileWithoutExtension) });
}
publishFormParams.name = parseName(fileName);
}
function handleFileChange(fileWithPath: FileWithPath) {
if (fileWithPath) {
updatePublishForm({ filePath: fileWithPath.path });
}
// File path is not supported on web for security reasons so we use the name instead.
setCurrentFile(file.path || file.name);
updatePublishForm(publishFormParams);
}
const showFileUpload = mode === PUBLISH_MODES.FILE;
@ -335,14 +325,12 @@ function PublishFile(props: Props) {
{showFileUpload && (
<>
<FileSelector
type="openFile"
label={__('File')}
disabled={disabled}
currentPath={currentFile}
onFileChosen={handleFileChange}
// https://stackoverflow.com/questions/19107685/safari-input-type-file-accept-video-ignores-mp4-files
placeholder={__('Select file to upload')}
readFile={false}
/>
{getUploadMessage()}
</>

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