Compare commits

...

40 commits

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

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

* changelog

* update github action xcode version

* update electronbuilder for macos

* try use_hard_links=false

* no USE_HARD_LINKS

* upgrate electron-builder 23_3_3

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

* try mac install homebrew, python2

* typo and ln /usr/bin/python

* oops

* try sudo

* try PYTHON_PATH

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

* fix master conflicts

* fixes

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

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

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

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

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

added input to select custom and default server when creating comment

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

btn moved to the correct component and syncs correctly

Fixed why it didn't show comments

Aligned input

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

margin removed

change using overflow

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

* Remove unnecessary escape sequence.
2022-10-17 11:07:33 -04:00
59 changed files with 920 additions and 541 deletions

View file

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

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

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

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

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

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

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

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

View file

@ -1,7 +1,53 @@
# 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

View file

@ -65,8 +65,8 @@ _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-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) |
| 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) |
## Usage

BIN
build/cert2023.pfx Normal file

Binary file not shown.

View file

@ -24,6 +24,8 @@ 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');
@ -353,6 +355,43 @@ ipcMain.handle('get-file-from-path', (event, path, readContents = true) => {
});
});
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();

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

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

View file

@ -1,6 +1,6 @@
{
"name": "lbry",
"version": "0.53.5",
"version": "0.53.9",
"description": "A browser for the LBRY network, a digital marketplace controlled by its users.",
"keywords": [
"lbry"
@ -51,6 +51,7 @@
"electron-notarize": "^1.0.0",
"electron-updater": "^4.2.4",
"express": "^4.17.1",
"ffmpeg-probe": "^1.0.6",
"humanize-duration": "^3.27.0",
"match-sorter": "^6.3.0",
"mime": "^3.0.0",
@ -114,7 +115,7 @@
"devtron": "^1.4.0",
"dotenv-defaults": "^2.0.1",
"dotenv-webpack": "^1.8.0",
"electron": "17.0.0",
"electron": "17.2.0",
"electron-builder": "^22.10.5",
"electron-devtools-installer": "^3.1.1",
"electron-is-dev": "^0.3.0",
@ -216,7 +217,7 @@
"yarn": "^1.3"
},
"lbrySettings": {
"lbrynetDaemonVersion": "0.110.0",
"lbrynetDaemonVersion": "0.113.0",
"lbrynetDaemonUrlTemplate": "https://github.com/lbryio/lbry/releases/download/vDAEMONVER/lbrynet-OSNAME.zip",
"lbrynetDaemonDir": "static/daemon",
"lbrynetDaemonFileName": "lbrynet"

View file

@ -2318,5 +2318,9 @@
"Odysee Connect --[Section in Help Page]--": "Odysee Connect",
"Your hub has blocked this content because it subscribes to the following blocking channel:": "Your hub has blocked this content because it subscribes to the following blocking channel:",
"Your hub has blocked access to this content do to a complaint received under the US Digital Millennium Copyright Act.": "Your hub has blocked access to this content do to a complaint received under the US Digital Millennium Copyright Act.",
"Autoplay Next is on.": "Autoplay Next is on.",
"This will be visible in a few minutes after you submit this form.": "This will be visible in a few minutes after you submit this form.",
"Anon --[used in <%anonymous% Reposted>]--": "Anon",
"Your update is now pending. It will take a few minutes to appear for other users.": "Your update is now pending. It will take a few minutes to appear for other users.",
"--end--": "--end--"
}

View file

@ -1,4 +1,11 @@
// @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';
@ -14,9 +21,6 @@ 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',
@ -68,114 +72,10 @@ 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) => {
amountOfBufferEvents = amountOfBufferEvents + 1;
amountOfBufferTimeInMS = amountOfBufferTimeInMS + data.bufferDuration;
},
onDispose: () => {
stopWatchmanInterval();
// stub
},
/**
* Is told whether video is being started or paused, and adjusts interval accordingly
@ -183,40 +83,9 @@ const analytics: Analytics = {
* @param {object} passedPlayer - VideoJS Player object
*/
videoIsPlaying: (isPlaying, passedPlayer) => {
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();
}
// stub
},
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);
},
@ -382,24 +251,9 @@ function sendMatomoEvent(category, action, name, value) {
}
}
// Prometheus
// function sendPromMetric(name: string, value?: number) {
// if (IS_WEB) {
// let url = new URL(SDK_API_PATH + '/metric/ui');
// const params = { name: name, value: value ? value.toString() : '' };
// url.search = new URLSearchParams(params).toString();
// return fetch(url, { method: 'post' });
// }
// }
const MatomoInstance = new MatomoTracker({
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

@ -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 } from 'component/common/form';
import { FormField, FormFieldAreaAdvanced } from 'component/common/form';
import Button from 'component/button';
import TagsSearch from 'component/tagsSearch';
import ErrorText from 'component/common/error-text';
@ -376,7 +376,7 @@ function ChannelForm(props: Props) {
onChange={(e) => setParams({ ...params, title: e.target.value })}
maxLength={MAX_TITLE_LEN}
/>
<FormField
<FormFieldAreaAdvanced
type="markdown"
name="content_description2"
label={__('Description')}

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 } from 'component/common/form';
import { FormField, FormFieldAreaAdvanced } from 'component/common/form';
import { handleBidChange } from 'util/publish';
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import { INVALID_NAME_ERROR } from 'constants/claim';
@ -371,7 +371,7 @@ function CollectionForm(props: Props) {
usePublishFormMode
/>
</fieldset-section>
<FormField
<FormFieldAreaAdvanced
type="markdown"
name="content_description2"
label={__('Description')}

View file

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

View file

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

View file

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

View file

@ -23,6 +23,9 @@ 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;
@ -56,15 +59,19 @@ 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 = {
fetchTopLevelComments: doCommentList,
fetchComment: doCommentById,
fetchReacts: doCommentReactList,
resetComments: doCommentReset,
doResolveUris,
};
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)),
});
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 } from 'config';
import { ENABLE_COMMENT_REACTIONS, COMMENT_SERVER_API, COMMENT_SERVER_NAME } from 'config';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import { getCommentsListTitle } from 'util/comments';
import * as ICONS from 'constants/icons';
@ -15,6 +15,8 @@ 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;
@ -52,6 +54,9 @@ 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) {
@ -80,11 +85,17 @@ 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);
@ -255,7 +266,16 @@ export default function CommentList(props: Props) {
}, [alreadyResolved, doResolveUris, topLevelComments]);
const commentProps = { isTopLevel: true, threadDepth: 3, uri, claimIsMine, linkedCommentId };
const actionButtonsProps = { totalComments, sort, changeSort, setPage };
const actionButtonsProps = {
totalComments,
sort,
changeSort,
setPage,
allServers,
commentServer,
defaultServer,
setCommentServer,
};
return (
<Card
@ -334,17 +354,21 @@ 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 } = actionButtonsProps;
const { totalComments, sort, changeSort, setPage, allServers, commentServer, setCommentServer, defaultServer } =
actionButtonsProps;
const sortButtonProps = { activeSort: sort, changeSort };
return (
<>
<div className={'comment__actions-row'}>
{totalComments > 1 && ENABLE_COMMENT_REACTIONS && (
<span className="comment__sort">
<div className="comment__sort-group">
<SortButton {...sortButtonProps} label={__('Best')} icon={ICONS.BEST} sortOption={SORT_BY.POPULARITY} />
<SortButton
{...sortButtonProps}
@ -353,11 +377,39 @@ const CommentActionButtons = (actionButtonsProps: ActionButtonsProps) => {
sortOption={SORT_BY.CONTROVERSY}
/>
<SortButton {...sortButtonProps} label={__('New')} icon={ICONS.NEW} sortOption={SORT_BY.NEWEST} />
</span>
</div>
)}
<Button button="alt" icon={ICONS.REFRESH} title={__('Refresh')} onClick={() => setPage(0)} />
</>
{allServers.length >= 2 && (
<div className="button__selected-server">
<FormField
type="select-tiny"
onChange={function (x) {
const selectedServer = x.target.value;
setPage(0);
setCommentServer(selectedServer);
if (selectedServer === defaultServer.url) {
Comments.setServerUrl(undefined);
} else {
Comments.setServerUrl(selectedServer);
}
}}
value={commentServer}
>
{allServers.map(function (server) {
return (
<option key={server.url} value={server.url}>
{server.name}
</option>
);
})}
</FormField>
</div>
)}
<div className="button_refresh">
<Button button="alt" icon={ICONS.REFRESH} title={__('Refresh')} onClick={() => setPage(0)} />
</div>
</div>
);
};

View file

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

View file

@ -1,14 +1,7 @@
// @flow
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 = {
@ -21,19 +14,15 @@ 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,
@ -41,8 +30,6 @@ type Props = {
type?: string,
value?: string | number,
onChange?: (any) => any,
openEmoteMenu?: () => void,
quickActionHandler?: (any) => any,
render?: () => React$Node,
};
@ -72,21 +59,15 @@ 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;
@ -101,18 +82,10 @@ 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} />
@ -143,133 +116,22 @@ 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 || quickAction) && (
{label && (
<div className="form-field__two-column">
<label htmlFor={name}>{label}</label>
{quickAction}
</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>
<textarea
type={type}
id={name}
maxLength={textAreaMaxLength || FF_MAX_CHARS_DEFAULT}
ref={this.input}
{...inputProps}
/>
<div className="form-field__textarea-info">{countInfo}</div>
</fieldset-section>
);
default:

View file

@ -1,4 +1,5 @@
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,4 +2054,15 @@ export const icons = {
<path d="M12.5,23.24v-1A10.74,10.74,0,0,1,23.24,11.52" />
</g>
),
[ICONS.SIMPLE_EDITOR]: buildIcon(
<g>
<path d="M1 18V6c0-1 1-2 2-2h18c1 0 2 1 2 2v12c0 1-1 2-2 2H3c-1 0-2-1-2-2ZM5 7v4" />
</g>
),
[ICONS.ADVANCED_EDITOR]: buildIcon(
<g>
<path d="M1 20V4c0-1 1-2 2-2h18c1 0 2 1 2 2v16c0 1-1 2-2 2H3c-1 0-2-1-2-2ZM1 11h22" />
<path d="M5 8V6h2v2H5ZM11 8V6h2v2h-2ZM17 8V6h2v2h-2ZM5 14v4" />
</g>
),
};

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 } from 'effects/use-screensize';
import { useIsMobile, useIsMediumScreen } from 'effects/use-screensize';
import debounce from 'util/debounce';
import { useHistory } from 'react-router';
import { isURIEqual } from 'util/lbryURI';
@ -132,6 +132,7 @@ 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();
@ -343,7 +344,8 @@ export default function FileRenderFloating(props: Props) {
'content__viewer--floating': isFloating,
'content__viewer--inline': !isFloating,
'content__viewer--secondary': isComment,
'content__viewer--theater-mode': !isFloating && videoTheaterMode && playingUri?.uri === primaryUri,
'content__viewer--theater-mode':
!isFloating && videoTheaterMode && !isMediumScreen && playingUri?.uri === primaryUri,
'content__viewer--disable-click': wasDragging,
})}
style={

View file

@ -9,6 +9,7 @@ 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';
@ -63,6 +64,7 @@ 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>();
@ -151,7 +153,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,
'content__cover--theater-mode': videoTheaterMode && !isMediumScreen,
'content__cover--text': isText,
'card__media--nsfw': obscurePreview,
})}

View file

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

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,
'main-wrapper__inner--theater-mode': isOnFilePage && videoTheaterMode && !isMediumScreen,
})}
>
{!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 && !isMarkdown,
'main--theater-mode': isOnFilePage && videoTheaterMode && !isMediumScreen && !isMarkdown,
})}
>
{children}

View file

@ -1,6 +1,6 @@
// @flow
import React, { useEffect } from 'react';
import { FormField } from 'component/common/form';
import { FormFieldAreaAdvanced } from 'component/common/form';
type Props = {
uri: ?string,
@ -99,7 +99,7 @@ function PostEditor(props: Props) {
]);
return (
<FormField
<FormFieldAreaAdvanced
type={'markdown'}
name="content_post"
label={label}

View file

@ -1,7 +1,7 @@
// @flow
import { FF_MAX_CHARS_IN_DESCRIPTION } from 'constants/form-field';
import React from 'react';
import { FormField } from 'component/common/form';
import { FormFieldAreaAdvanced } 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={
<FormField
<FormFieldAreaAdvanced
type={advancedEditor ? 'markdown' : 'textarea'}
name="content_description"
label={__('Description')}

View file

@ -14,7 +14,7 @@ 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,
@ -99,18 +99,27 @@ function PublishFile(props: Props) {
if (!filePath) {
return;
}
async function readSelectedFile() {
async function readSelectedFileDetails() {
// Read the file to get the file's duration (if possible)
// and offer transcoding it.
const readFileContents = true;
const result = await ipcRenderer.invoke('get-file-from-path', filePath, readFileContents);
const file = new File([result.buffer], result.name, {
type: result.mime,
});
const fileWithPath = { file, path: result.path };
processSelectedFile(fileWithPath);
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);
}
readSelectedFile();
readSelectedFileDetails();
}, [filePath]);
useEffect(() => {
@ -219,11 +228,11 @@ function PublishFile(props: Props) {
}
}
function processSelectedFile(fileWithPath: FileWithPath, clearName = true) {
function processSelectedFile(fileData: FileData, clearName = true) {
window.URL = window.URL || window.webkitURL;
// select file, start to select a new one, then cancel
if (!fileWithPath) {
if (!fileData || fileData.error) {
if (isStillEditing || !clearName) {
updatePublishForm({ filePath: '' });
} else {
@ -233,8 +242,11 @@ function PublishFile(props: Props) {
}
// if video, extract duration so we can warn about bitrate if (typeof file !== 'string')
const file = fileWithPath.file;
const contentType = file.type && file.type.split('/');
const file = fileData.file;
// Check to see if it's a video and if mp4
const contentType = fileData.mimeType && fileData.mimeType.split('/'); // get this from electron side
const duration = fileData.duration;
const size = fileData.size;
const isVideo = contentType && contentType[0] === 'video';
const isMp4 = contentType && contentType[1] === 'mp4';
@ -242,34 +254,25 @@ function PublishFile(props: Props) {
if (contentType && contentType[0] === 'text') {
isTextPost = contentType[1] === 'plain' || contentType[1] === 'markdown';
setCurrentFileType(contentType);
} else if (file.name) {
setCurrentFileType(contentType.join('/'));
} else if (path.parse(fileData.path).ext) {
// If user's machine is missing a valid content type registration
// for markdown content: text/markdown, file extension will be used instead
const extension = file.name.split('.').pop();
const extension = path.parse(fileData.path).ext;
isTextPost = MARKDOWN_FILE_EXTENSIONS.includes(extension);
}
if (isVideo) {
if (isMp4) {
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);
updateFileInfo(duration || 0, size, isVideo);
} else {
updateFileInfo(0, file.size, isVideo);
updateFileInfo(duration || 0, size, isVideo);
}
} else {
updateFileInfo(0, file.size, isVideo);
updateFileInfo(0, size, isVideo);
}
if (isTextPost) {
if (isTextPost && file) {
// Create reader
const reader = new FileReader();
// Handler for file reader
@ -283,7 +286,7 @@ function PublishFile(props: Props) {
// Strip off extension and replace invalid characters
if (!isStillEditing) {
const fileWithoutExtension = name || (file.name && file.name.substring(0, file.name.lastIndexOf('.'))) || '';
const fileWithoutExtension = path.parse(fileData.path).name;
updatePublishForm({ name: parseName(fileWithoutExtension) });
}
}

View file

@ -208,7 +208,6 @@ function PublishForm(props: Props) {
isNameValid(name) &&
title &&
bid &&
thumbnail &&
!bidError &&
!emptyPostError &&
!(thumbnailError && !thumbnailUploaded) &&

View file

@ -106,7 +106,7 @@ function SelectThumbnail(props: Props) {
__('This will be visible in a few minutes after you submit this form.')}
<img
style={{ display: 'none' }}
src={thumbnail}
src={thumbnail || ThumbnailMissingImage}
alt={__('Thumbnail Preview')}
onError={() => {
if (updateThumbnailParams) {

View file

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

View file

@ -186,3 +186,5 @@ export const MYSTERIES = 'Mysteries';
export const TECHNOLOGY = 'Technology';
export const EMOJI = 'Emoji';
export const STICKER = 'Sticker';
export const SIMPLE_EDITOR = 'SimpleEditor';
export const ADVANCED_EDITOR = 'AdvancedEditor';

View file

@ -11,7 +11,8 @@ export const SUPPORT = 'support';
export const CHANNEL = 'channel';
export const PUBLISH = 'publish';
export const REPOST = 'repost';
export const DROPDOWN_TYPES = [ALL, SENT, RECEIVED, SUPPORT, CHANNEL, PUBLISH, REPOST];
export const COLLECTION = 'collection';
export const DROPDOWN_TYPES = [ALL, SENT, RECEIVED, SUPPORT, CHANNEL, PUBLISH, REPOST, COLLECTION];
// dropdown subtypes
export const TIP = 'tip';
export const PURCHASE = 'purchase';

View file

@ -36,7 +36,7 @@ const Lbry = {
// Returns a human readable media type based on the content type or extension of a file that is returned by the sdk
getMediaType: (contentType: ?string, fileName: ?string) => {
if (fileName) {
if (fileName && fileName.split('.').length > 1) {
const formats = [
[/\.(mp4|m4v|webm|flv|f4v|ogv)$/i, 'video'],
[/\.(mp3|m4a|aac|wav|flac|ogg|opus)$/i, 'audio'],

View file

@ -1,6 +1,7 @@
// @flow
import * as React from 'react';
import classnames from 'classnames';
import { useIsMediumScreen } from 'effects/use-screensize';
import Page from 'component/page';
import * as RENDER_MODES from 'constants/file_render_modes';
import FileTitleSection from 'component/fileTitleSection';
@ -59,6 +60,7 @@ function FilePage(props: Props) {
} = props;
const cost = costInfo ? costInfo.cost : null;
const hasFileInfo = fileInfo !== undefined;
const isMediumScreen = useIsMediumScreen();
const isMarkdown = renderMode === RENDER_MODES.MARKDOWN;
const videoPlayedEnoughToResetPosition = React.useMemo(() => {
const durationInSecs =
@ -169,8 +171,10 @@ function FilePage(props: Props) {
<div className={classnames('section card-stack', `file-page__${renderMode}`)}>
<FileTitleSection uri={uri} isNsfwBlocked />
</div>
{collection && !isMarkdown && !videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />}
{!collection && !isMarkdown && !videoTheaterMode && <RecommendedContent uri={uri} />}
{collection && !isMarkdown && !videoTheaterMode && !isMediumScreen && (
<CollectionContent id={collectionId} uri={uri} />
)}
{!collection && !isMarkdown && !videoTheaterMode && !isMediumScreen && <RecommendedContent uri={uri} />}
</Page>
);
}
@ -187,13 +191,17 @@ function FilePage(props: Props) {
{commentsDisabled && <Empty text={__('The creator of this content has disabled comments.')} />}
{!commentsDisabled && <CommentsList uri={uri} linkedCommentId={linkedCommentId} />}
</div>
{!collection && !isMarkdown && videoTheaterMode && <RecommendedContent uri={uri} />}
{collection && !isMarkdown && videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />}
{!collection && !isMarkdown && videoTheaterMode && !isMediumScreen && <RecommendedContent uri={uri} />}
{collection && !isMarkdown && videoTheaterMode && !isMediumScreen && (
<CollectionContent id={collectionId} uri={uri} />
)}
</div>
)}
</div>
{collection && !isMarkdown && !videoTheaterMode && <CollectionContent id={collectionId} uri={uri} />}
{!collection && !isMarkdown && !videoTheaterMode && <RecommendedContent uri={uri} />}
{collection && !isMarkdown && !videoTheaterMode && !isMediumScreen && (
<CollectionContent id={collectionId} uri={uri} />
)}
{!collection && !isMarkdown && !videoTheaterMode && !isMediumScreen && <RecommendedContent uri={uri} />}
{isMarkdown && (
<div className="file-page__post-comments">
{!commentsDisabled && <CommentsList uri={uri} linkedCommentId={linkedCommentId} commentsAreExpanded />}

View file

@ -62,7 +62,7 @@ class ReportPage extends React.Component {
name="message"
stretch
value={this.state.message}
onChange={event => {
onChange={(event) => {
this.onMessageChange(event);
}}
placeholder={__('Description of your issue or feature request')}
@ -71,7 +71,7 @@ class ReportPage extends React.Component {
<div className="section__actions">
<Button
button="primary"
onClick={event => {
onClick={(event) => {
this.submitMessage(event);
}}
className={`button-block button-primary ${this.state.submitting ? 'disabled' : ''}`}

View file

@ -458,32 +458,34 @@ export function doAnalyticsView(uri, timeToStart) {
}
export function doAnalyticsBuffer(uri, bufferData) {
return (dispatch, getState) => {
const state = getState();
const claim = selectClaimForUri(state, uri);
const user = selectUser(state);
const {
value: { video, audio, source },
} = claim;
const timeAtBuffer = parseInt(bufferData.currentTime ? bufferData.currentTime * 1000 : 0);
const bufferDuration = parseInt(bufferData.secondsToLoad ? bufferData.secondsToLoad * 1000 : 0);
const fileDurationInSeconds = (video && video.duration) || (audio && audio.duration);
const fileSize = source.size; // size in bytes
const fileSizeInBits = fileSize * 8;
const bitRate = parseInt(fileSizeInBits / fileDurationInSeconds);
const userId = user && user.id.toString();
return () => {
// return (dispatch, getState) => {
// const state = getState();
// const claim = selectClaimForUri(state, uri);
// const user = selectUser(state);
// const {
// value: { video, audio, source },
// } = claim;
// const timeAtBuffer = parseInt(bufferData.currentTime ? bufferData.currentTime * 1000 : 0);
// const bufferDuration = parseInt(bufferData.secondsToLoad ? bufferData.secondsToLoad * 1000 : 0);
// const fileDurationInSeconds = (video && video.duration) || (audio && audio.duration);
// const fileSize = source.size; // size in bytes
// const fileSizeInBits = fileSize * 8;
// const bitRate = parseInt(fileSizeInBits / fileDurationInSeconds);
// const userId = user && user.id.toString();
// if there's a logged in user, send buffer event data to watchman
if (userId) {
analytics.videoBufferEvent(claim, {
timeAtBuffer,
bufferDuration,
bitRate,
userId,
duration: fileDurationInSeconds,
playerPoweredBy: bufferData.playerPoweredBy,
readyState: bufferData.readyState,
});
}
// if (<condition>) {
// STUB: any buffer events here
// analytics.videoBufferEvent(claim, {
// timeAtBuffer,
// bufferDuration,
// bitRate,
// userId,
// duration: fileDurationInSeconds,
// playerPoweredBy: bufferData.playerPoweredBy,
// readyState: bufferData.readyState,
// });
// }
};
}

View file

@ -9,7 +9,7 @@ import {
selectClaimForUri,
} from 'redux/selectors/claims';
import { swapKeyAndValue } from 'util/swap-json';
import { getChannelFromClaim } from 'util/claim';
import { getChannelFromClaim, isChannelClaim } from 'util/claim';
// Returns the entire subscriptions state
const selectState = (state) => state.subscriptions || {};
@ -114,12 +114,18 @@ export const makeSelectChannelInSubscriptions = (uri) =>
createSelector(selectSubscriptions, (subscriptions) => subscriptions.some((sub) => sub.uri === uri));
export const selectIsSubscribedForUri = createCachedSelector(
(state, uri) => uri,
selectClaimForUri,
selectSubscriptions,
(claim, subscriptions) => {
(uri, claim, subscriptions) => {
const channelClaim = getChannelFromClaim(claim);
if (channelClaim) {
const uri = channelClaim.permanent_url;
const permanentUrl = channelClaim.permanent_url;
return subscriptions.some((sub) => isURIEqual(sub.uri, permanentUrl));
}
// If it failed, it could be an abandoned channel. Try parseURI:
if (isChannelClaim(claim, uri)) {
return subscriptions.some((sub) => isURIEqual(sub.uri, uri));
}
return false;

View file

@ -167,7 +167,7 @@ a.button--alt {
.vjs-button--theater-mode.vjs-button {
display: none;
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
display: block;
order: 1;
background-repeat: no-repeat;

View file

@ -41,7 +41,7 @@
margin: 0px var(--spacing-xxs);
}
.button + .commentCreate {
.button + .comment-create {
margin-top: var(--spacing-xxs);
}
}
@ -274,6 +274,21 @@
display: flex;
flex-direction: column;
overflow: hidden;
.select--slim {
display: flex;
margin-left: var(--spacing-s);
margin-bottom: var(--spacing-s);
@media (min-width: $breakpoint-small) {
max-width: none;
select {
padding: 0 var(--spacing-xs);
padding-right: var(--spacing-l);
}
}
}
}
.card--enable-overflow {
@ -318,6 +333,7 @@
align-self: flex-start;
.button--alt {
padding-top: 2px;
padding: 0 var(--spacing-s);
}
.comment__sort {
.button--alt {
@ -599,7 +615,7 @@
@media (max-width: $breakpoint-small) {
font-size: var(--font-small);
border-bottom: none;
.button--link {
font-size: var(--font-xsmall);
margin: 0px;
@ -642,3 +658,25 @@
}
}
}
.button__selected-server {
display: inline;
float: right;
select {
width: 12rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
@media (max-width: $breakpoint-small) {
select {
width: 8rem;
}
}
}
.button_refresh {
display: inline;
float: right;
margin-left: var(--spacing-s);
}

View file

@ -621,7 +621,7 @@
@media (max-width: $breakpoint-xsmall) {
-webkit-line-clamp: 2 !important;
}
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
-webkit-line-clamp: 1 !important;
}
}

View file

@ -7,7 +7,7 @@ $thumbnailWidthSmall: 1rem;
position: relative;
}
.commentCreate {
.comment-create {
font-size: var(--font-small);
position: relative;
@ -135,12 +135,12 @@ $thumbnailWidthSmall: 1rem;
}
}
.commentCreate--reply {
.comment-create--reply {
margin-top: var(--spacing-m);
position: relative;
}
.commentCreate--nestedReply {
.comment-create--nestedReply {
margin-top: var(--spacing-s);
margin-left: calc((#{$thumbnailWidthSmall} + var(--spacing-xs)) * 2 + var(--spacing-m) + 4px);
@ -149,27 +149,40 @@ $thumbnailWidthSmall: 1rem;
}
}
.commentCreate--bottom {
.comment-create--bottom {
padding-bottom: 0;
}
.comment-create__header {
display: flex;
justify-content: space-between;
align-items: flex-end;
.comment-create__header-button {
display: flex;
justify-content: space-between;
}
.button--alt {
padding: var(--spacing-xs);
height: unset;
margin-bottom: var(--spacing-xxs);
background: unset;
}
}
.comment-create__label-wrapper {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: baseline;
flex-wrap: wrap;
width: 100%;
max-width: 50%;
.comment-create__label {
white-space: nowrap;
margin-right: var(--spacing-xs);
}
fieldset-section {
max-width: 10rem;
}
@media (max-width: $breakpoint-small) {
fieldset-section {
font-size: var(--font-xxsmall);
@ -179,14 +192,14 @@ $thumbnailWidthSmall: 1rem;
font-size: var(--font-xxsmall);
}
select {
height: 1rem;
margin: var(--spacing-xxs) 0px;
}
//select {
// height: 1rem;
// margin: var(--spacing-xxs) 0px;
//}
}
}
.commentCreate__supportCommentPreview {
.comment-create__supportCommentPreview {
display: flex;
align-items: center;
border: 1px solid var(--color-border);
@ -194,7 +207,7 @@ $thumbnailWidthSmall: 1rem;
padding: var(--spacing-s);
margin: var(--spacing-s) 0;
.commentCreate__supportCommentPreviewAmount {
.comment-create__supportCommentPreviewAmount {
margin-right: var(--spacing-m);
font-size: var(--font-large);
}
@ -223,8 +236,8 @@ $thumbnailWidthSmall: 1rem;
}
}
.commentCreate__stickerPreview {
@extend .commentCreate;
.comment-create__stickerPreview {
@extend .comment-create;
display: flex;
background-color: var(--color-header-background);
border-radius: var(--border-radius);
@ -234,12 +247,12 @@ $thumbnailWidthSmall: 1rem;
width: 100%;
height: 10rem;
.commentCreate__stickerPreviewInfo {
.comment-create__stickerPreviewInfo {
display: flex;
align-items: flex-start;
}
.commentCreate__stickerPreviewImage {
.comment-create__stickerPreviewImage {
width: 100%;
height: 100%;
margin-left: var(--spacing-m);

View file

@ -37,16 +37,18 @@ $thumbnailWidthSmall: 1rem;
}
.comment__sort {
margin-right: var(--spacing-s);
display: inline-block;
@media (min-width: $breakpoint-small) {
margin-top: 0;
display: inline;
}
@media (max-width: $breakpoint-small) {
margin-right: 0;
}
}
.comment__actions-row {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.comment {
@ -505,7 +507,7 @@ $thumbnailWidthSmall: 1rem;
min-width: 100%;
max-width: 100%;
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
min-width: 40%;
max-width: 40%;
}
@ -547,7 +549,7 @@ $thumbnailWidthSmall: 1rem;
}
}
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
margin: 0 var(--spacing-xs);
}
@ -562,7 +564,7 @@ $thumbnailWidthSmall: 1rem;
padding-left: var(--spacing-m);
border-left: 4px solid var(--color-border);
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
margin-top: 0;
margin-left: var(--spacing-s);
}

View file

@ -29,7 +29,12 @@ select,
background-color: var(--color-secondary);
}
}
textarea {
height: var(--height-input);
border-radius: var(--border-radius);
color: var(--color-input);
background-color: var(--color-input-bg);
}
@media (min-width: $breakpoint-small) {
textarea {
height: var(--height-input);
@ -532,6 +537,7 @@ fieldset-group {
}
.form-field__quick-action {
text-align: right;
font-size: var(--font-xsmall);
}

View file

@ -73,7 +73,7 @@ body {
}
.sidebar--pusher--open {
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
transform: scaleX(0.9) translateX(calc(5.4 * var(--spacing-l))) scaleY(0.9);
}
}
@ -155,7 +155,7 @@ body {
}
}
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
flex-direction: row;
}
@media (max-width: $breakpoint-medium) {
@ -461,7 +461,6 @@ body {
margin-left: 0;
margin-right: 0;
margin-top: 0;
width: 100vw;
max-width: none;
> :first-child {
@ -804,7 +803,7 @@ body {
}
}
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
> :first-child {
width: calc(30% - var(--spacing-l));
max-width: 25rem;

View file

@ -70,7 +70,7 @@
color: var(--color-brand-contrast) !important;
}
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
overflow-y: hidden;
justify-content: space-between;
@ -235,7 +235,7 @@
@extend .navigation-link--highlighted;
}
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
text-align: left;
margin-bottom: 0;

View file

@ -32,13 +32,17 @@ $contentMaxWidth: 60rem;
}
}
.commentCreate {
.comment-create {
border-top: 1px solid var(--color-border);
padding-top: var(--spacing-s);
.commentCreate__label {
.comment-create__label {
color: var(--color-text);
}
.comment-create__header {
display: grid;
grid-template-columns: 3fr 1fr;
}
textarea,
select,
.button:not(.button--file-action) {
@ -81,7 +85,7 @@ $contentMaxWidth: 60rem;
}
}
.commentCreate,
.comment-create,
.comment__content {
margin: var(--spacing-m);
margin-bottom: 0;

View file

@ -85,7 +85,7 @@
border-radius: 0 !important;
}
.card__main-actions .commentCreate .MuiOutlinedInput-notchedOutline {
.card__main-actions .comment-create .MuiOutlinedInput-notchedOutline {
border: 1px solid var(--color-border) !important;
border-radius: var(--border-radius) !important;
}
@ -104,7 +104,7 @@
textarea {
border: none;
margin: 9px 0px;
padding: var(--spacing-xxs) var(--spacing-xxs);
}
button {
@ -320,7 +320,7 @@
}
@media (max-width: $breakpoint-small) {
.commentCreate {
.comment-create {
.section__actions {
.button {
background-color: var(--color-header-button);

View file

@ -358,7 +358,7 @@
max-width: unset;
}
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
width: 40%;
.button,
@ -375,7 +375,7 @@
}
.settings__row--value--multirow {
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
width: 80%;
margin-top: var(--spacing-l);
@ -389,7 +389,7 @@
}
.settings__row--value--vertical-separator {
@media (min-width: $breakpoint-medium) {
@media not all and (max-width: $breakpoint-medium) {
border-left: 1px solid var(--color-border);
}
}

View file

@ -62,6 +62,9 @@ $spacing-width: 36px;
--floating-viewer-container-height: calc(var(--floating-viewer-height) + var(--floating-viewer-info-height));
--option-select-width: 8rem;
--input-select-server-min-width: 100px;
--input-select-server-max-width: 250px;
// Text
--text-max-width: 660px;
--text-link-padding: 4px;

View file

@ -1,6 +1,6 @@
// JSON parser
const parseJson = (data, filters = []) => {
const list = data.map(item => {
const list = data.map((item) => {
const temp = {};
// Apply filters
Object.entries(item).forEach(([key, value]) => {
@ -17,7 +17,7 @@ const parseJson = (data, filters = []) => {
// https://gist.github.com/btzr-io/55c3450ea3d709fc57540e762899fb85
const parseCsv = (data, filters = []) => {
// Get items for header
const getHeaders = item => {
const getHeaders = (item) => {
const list = [];
// Apply filters
Object.entries(item).forEach(([key]) => {
@ -28,13 +28,16 @@ const parseCsv = (data, filters = []) => {
};
// Get rows content
const getData = list =>
const getData = (list) =>
list
.map(item => {
.map((item) => {
const row = [];
// Apply filters
Object.entries(item).forEach(([key, value]) => {
if (!filters.includes(key)) row.push(value);
if (!filters.includes(key)) {
const sanitizedValue = '"' + value + '"';
row.push(sanitizedValue);
}
});
// return rows
return row.join(',');
@ -50,8 +53,8 @@ const parseData = (data, format, filters = []) => {
const valid = data && data[0] && format;
// Pick a format
const formats = {
csv: list => parseCsv(list, filters),
json: list => parseJson(list, filters),
csv: (list) => parseCsv(list, filters),
json: (list) => parseJson(list, filters),
};
// Return parsed data: JSON || CSV

View file

@ -95,7 +95,7 @@ let baseConfig = {
},
plugins: [
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
new webpack.IgnorePlugin({resourceRegExp: /^\.\/locale$/, contextRegExp: /moment$/}),
new webpack.EnvironmentPlugin(['NODE_ENV']),
new DefinePlugin({
__static: `"${path.join(__dirname, 'static').replace(/\\/g, '\\\\')}"`,

185
yarn.lock
View file

@ -3080,6 +3080,24 @@ __metadata:
languageName: node
linkType: hard
"@videojs/http-streaming@npm:2.15.0":
version: 2.15.0
resolution: "@videojs/http-streaming@npm:2.15.0"
dependencies:
"@babel/runtime": ^7.12.5
"@videojs/vhs-utils": 3.0.5
aes-decrypter: 3.1.3
global: ^4.4.0
m3u8-parser: 4.8.0
mpd-parser: 0.22.0
mux.js: 6.0.1
video.js: ^6 || ^7
peerDependencies:
video.js: ^6 || ^7
checksum: 3b04c78c42532419216abb0eae685bfa873aeea77a015875d1cfcc0e5f4eda7da3868e6362320b2843562ba735f728a9b7895384d01c673bc3845e3ef7ac3452
languageName: node
linkType: hard
"@videojs/vhs-utils@npm:3.0.5, @videojs/vhs-utils@npm:^3.0.4, @videojs/vhs-utils@npm:^3.0.5":
version: 3.0.5
resolution: "@videojs/vhs-utils@npm:3.0.5"
@ -3291,9 +3309,9 @@ __metadata:
linkType: hard
"@xmldom/xmldom@npm:^0.7.2":
version: 0.7.5
resolution: "@xmldom/xmldom@npm:0.7.5"
checksum: 8d7ec35c1ef6183b4f621df08e01d7e61f244fb964a4719025e65fe6ac06fac418919be64fb40fe5908e69158ef728f2d936daa082db326fe04603012b5f2a84
version: 0.7.6
resolution: "@xmldom/xmldom@npm:0.7.6"
checksum: 3c31dcd909aaefd65090033bd45e0aca42d636a9ae43c7313f4be87de570d046abba28362810d63c0718d6f08a9ce5f8da27b87437afc24d2290b6d4e75a6eee
languageName: node
linkType: hard
@ -3839,8 +3857,8 @@ __metadata:
linkType: hard
"asar@npm:^3.0.3":
version: 3.1.0
resolution: "asar@npm:3.1.0"
version: 3.2.0
resolution: "asar@npm:3.2.0"
dependencies:
"@types/glob": ^7.1.1
chromium-pickle-js: ^0.2.0
@ -3852,7 +3870,7 @@ __metadata:
optional: true
bin:
asar: bin/asar.js
checksum: facc80845639fa4f9e1d1aa40b96adbd1e8b6fee0725d287e8c8e30a69b235cd5b7131b7b09ff700da06c919dd0595b373e372c55722808f983fdb71ef0d5399
checksum: f7d30b45970b053252ac124230bf319459d0728d7f6dedbe2f765cd2a83792d5a716d2c3f2861ceda69372b401f335e1f46460335169eadd0e91a0904a4f5a15
languageName: node
linkType: hard
@ -5297,6 +5315,17 @@ __metadata:
languageName: node
linkType: hard
"cliui@npm:^8.0.1":
version: 8.0.1
resolution: "cliui@npm:8.0.1"
dependencies:
string-width: ^4.2.0
strip-ansi: ^6.0.1
wrap-ansi: ^7.0.0
checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56
languageName: node
linkType: hard
"clone-deep@npm:^4.0.1":
version: 4.0.1
resolution: "clone-deep@npm:4.0.1"
@ -7258,16 +7287,16 @@ __metadata:
languageName: node
linkType: hard
"electron@npm:17.0.0":
version: 17.0.0
resolution: "electron@npm:17.0.0"
"electron@npm:17.2.0":
version: 17.2.0
resolution: "electron@npm:17.2.0"
dependencies:
"@electron/get": ^1.13.0
"@types/node": ^14.6.2
extract-zip: ^1.0.3
bin:
electron: cli.js
checksum: c945a1a5475ba66ca70637b150b3f10ea9d28b1f2167a9d8d2137e087785f647e1153f4335f898238470392ed689e3d207b2b96928025c5dd092d6f601e21414
checksum: f345311f9c79f0852c3116ede2b83a439e8c1c3b2a40c85e5b5912962571fe1d1c19af8231e0215f47da735ebe6064cbc812ae1bdf4f9638de538912b1f6ab7e
languageName: node
linkType: hard
@ -7976,6 +8005,21 @@ __metadata:
languageName: node
linkType: hard
"execa@npm:^0.10.0":
version: 0.10.0
resolution: "execa@npm:0.10.0"
dependencies:
cross-spawn: ^6.0.0
get-stream: ^3.0.0
is-stream: ^1.1.0
npm-run-path: ^2.0.0
p-finally: ^1.0.0
signal-exit: ^3.0.0
strip-eof: ^1.0.0
checksum: da132af2b209e69d79f91751ac6d15ddbb8d9414f9e5f7a53405232679a3dca00fe11eb14e0cd5c2c374a749061410a7717fcc3094f6dd779cf4d259faa58d9a
languageName: node
linkType: hard
"execa@npm:^0.7.0":
version: 0.7.0
resolution: "execa@npm:0.7.0"
@ -8290,6 +8334,15 @@ __metadata:
languageName: node
linkType: hard
"ffmpeg-probe@npm:^1.0.6":
version: 1.0.6
resolution: "ffmpeg-probe@npm:1.0.6"
dependencies:
execa: ^0.10.0
checksum: fe649b2ca41bd48b521d7cc5741663d4c608d7bc596033ee9c76d4c3f5e739881a4d421bdcfa3ea60e28301eae7a85b72cd74d6266e661bccf9aea6578fcfe3c
languageName: node
linkType: hard
"figgy-pudding@npm:^3.5.1":
version: 3.5.2
resolution: "figgy-pudding@npm:3.5.2"
@ -9056,11 +9109,11 @@ __metadata:
linkType: hard
"global-dirs@npm:^3.0.0":
version: 3.0.0
resolution: "global-dirs@npm:3.0.0"
version: 3.0.1
resolution: "global-dirs@npm:3.0.1"
dependencies:
ini: 2.0.0
checksum: 953c17cf14bf6ee0e2100ae82a0d779934eed8a3ec5c94a7a4f37c5b3b592c31ea015fb9a15cf32484de13c79f4a814f3015152f3e1d65976cfbe47c1bfe4a88
checksum: 70147b80261601fd40ac02a104581432325c1c47329706acd773f3a6ce99bb36d1d996038c85ccacd482ad22258ec233c586b6a91535b1a116b89663d49d6438
languageName: node
linkType: hard
@ -11406,7 +11459,7 @@ __metadata:
devtron: ^1.4.0
dotenv-defaults: ^2.0.1
dotenv-webpack: ^1.8.0
electron: 17.0.0
electron: 17.2.0
electron-builder: ^22.10.5
electron-devtools-installer: ^3.1.1
electron-dl: ^3.2.0
@ -11432,6 +11485,7 @@ __metadata:
eslint-plugin-react-hooks: ^1.6.0
eslint-plugin-standard: ^4.0.1
express: ^4.17.1
ffmpeg-probe: ^1.0.6
file-loader: ^4.2.0
flow-bin: ^0.97.0
flow-typed: ^3.7.0
@ -12014,6 +12068,17 @@ __metadata:
languageName: node
linkType: hard
"m3u8-parser@npm:4.8.0":
version: 4.8.0
resolution: "m3u8-parser@npm:4.8.0"
dependencies:
"@babel/runtime": ^7.12.5
"@videojs/vhs-utils": ^3.0.5
global: ^4.4.0
checksum: 850a8c5cacdbdc154d4fa18c79af77fdf45ef4afcb3d8a0507d04e8d9ddb5c0a9d18cb48c8e23be707d007c063038cdeab0d3de23100e5c02050a7aff6e2b2b8
languageName: node
linkType: hard
"make-dir@npm:^1.0.0":
version: 1.3.0
resolution: "make-dir@npm:1.3.0"
@ -12686,6 +12751,20 @@ __metadata:
languageName: node
linkType: hard
"mpd-parser@npm:0.22.0":
version: 0.22.0
resolution: "mpd-parser@npm:0.22.0"
dependencies:
"@babel/runtime": ^7.12.5
"@videojs/vhs-utils": ^3.0.5
"@xmldom/xmldom": ^0.7.2
global: ^4.4.0
bin:
mpd-to-m3u8-json: bin/parse.js
checksum: 6266f03ec5eb9501e315ed567e70d9e1c8d114f84a8ec27b31ec5de877ab0cd80592dfb058e9abd7975734374f6f10cec1751ab8af90b577e902f0f38f868b6c
languageName: node
linkType: hard
"ms@npm:2.0.0":
version: 2.0.0
resolution: "ms@npm:2.0.0"
@ -16347,7 +16426,7 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.1.3, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5":
"semver@npm:^7.1.3, semver@npm:^7.2.1, semver@npm:^7.3.2, semver@npm:^7.3.5":
version: 7.3.7
resolution: "semver@npm:7.3.7"
dependencies:
@ -16358,6 +16437,17 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.3.4":
version: 7.3.8
resolution: "semver@npm:7.3.8"
dependencies:
lru-cache: ^6.0.0
bin:
semver: bin/semver.js
checksum: ba9c7cbbf2b7884696523450a61fee1a09930d888b7a8d7579025ad93d459b2d1949ee5bbfeb188b2be5f4ac163544c5e98491ad6152df34154feebc2cc337c1
languageName: node
linkType: hard
"send@npm:0.18.0":
version: 0.18.0
resolution: "send@npm:0.18.0"
@ -17598,7 +17688,20 @@ __metadata:
languageName: node
linkType: hard
"terser@npm:^4.1.2, terser@npm:^4.6.12, terser@npm:^4.6.3":
"terser@npm:^4.1.2":
version: 4.8.1
resolution: "terser@npm:4.8.1"
dependencies:
commander: ^2.20.0
source-map: ~0.6.1
source-map-support: ~0.5.12
bin:
terser: bin/terser
checksum: b342819bf7e82283059aaa3f22bb74deb1862d07573ba5a8947882190ad525fd9b44a15074986be083fd379c58b9a879457a330b66dcdb77b485c44267f9a55a
languageName: node
linkType: hard
"terser@npm:^4.6.12, terser@npm:^4.6.3":
version: 4.8.0
resolution: "terser@npm:4.8.0"
dependencies:
@ -18713,7 +18816,28 @@ __metadata:
languageName: node
linkType: hard
"video.js@npm:>=5.20.5, video.js@npm:^6 || ^7, video.js@npm:^7.14.3":
"video.js@npm:>=5.20.5":
version: 7.21.0
resolution: "video.js@npm:7.21.0"
dependencies:
"@babel/runtime": ^7.12.5
"@videojs/http-streaming": 2.15.0
"@videojs/vhs-utils": ^3.0.4
"@videojs/xhr": 2.6.0
aes-decrypter: 3.1.3
global: ^4.4.0
keycode: ^2.2.0
m3u8-parser: 4.8.0
mpd-parser: 0.22.0
mux.js: 6.0.1
safe-json-parse: 4.0.0
videojs-font: 3.2.0
videojs-vtt.js: ^0.15.4
checksum: 9eea846bb610daca8b926ff9d0662bedf2d36063e312222c3af7e95494de09b8ce3fd0592082853e7aaaf9c92b8e37e958a41332e241376d88c4970ee717e3ab
languageName: node
linkType: hard
"video.js@npm:^6 || ^7, video.js@npm:^7.14.3":
version: 7.19.2
resolution: "video.js@npm:7.19.2"
dependencies:
@ -18771,6 +18895,15 @@ __metadata:
languageName: node
linkType: hard
"videojs-vtt.js@npm:^0.15.4":
version: 0.15.4
resolution: "videojs-vtt.js@npm:0.15.4"
dependencies:
global: ^4.3.1
checksum: 10c6c861621d4314e7d4b60b7bef1afc60f1ac438879f6b3f22e8944d694c8e9dfc809a8187ed72f44e06c39a159044d8fa15e80695b9bf7b9bef99ea2740b70
languageName: node
linkType: hard
"villain-react@npm:^1.0.9":
version: 1.0.9
resolution: "villain-react@npm:1.0.9"
@ -19428,10 +19561,10 @@ __metadata:
languageName: node
linkType: hard
"yargs-parser@npm:^21.0.0":
version: 21.0.1
resolution: "yargs-parser@npm:21.0.1"
checksum: c3ea2ed12cad0377ce3096b3f138df8267edf7b1aa7d710cd502fe16af417bafe4443dd71b28158c22fcd1be5dfd0e86319597e47badf42ff83815485887323a
"yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"
checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c
languageName: node
linkType: hard
@ -19488,17 +19621,17 @@ __metadata:
linkType: hard
"yargs@npm:^17.0.1":
version: 17.5.1
resolution: "yargs@npm:17.5.1"
version: 17.6.2
resolution: "yargs@npm:17.6.2"
dependencies:
cliui: ^7.0.2
cliui: ^8.0.1
escalade: ^3.1.1
get-caller-file: ^2.0.5
require-directory: ^2.1.1
string-width: ^4.2.3
y18n: ^5.0.5
yargs-parser: ^21.0.0
checksum: 00d58a2c052937fa044834313f07910fd0a115dec5ee35919e857eeee3736b21a4eafa8264535800ba8bac312991ce785ecb8a51f4d2cc8c4676d865af1cfbde
yargs-parser: ^21.1.1
checksum: 47da1b0d854fa16d45a3ded57b716b013b2179022352a5f7467409da5a04a1eef5b3b3d97a2dfc13e8bbe5f2ffc0afe3bc6a4a72f8254e60f5a4bd7947138643
languageName: node
linkType: hard