lbry-desktop/ui/util/lbryURI.js
Rafael Saes 83dbe8ec7c
Playlists v2: Refactors, touch ups + Queue Mode (#1604)
* Playlists v2

* Style pass

* Change playlist items arrange icon

* Playlist card body open by default

* Refactor collectionEdit components

* Paginate & Refactor bid field

* Collection page changes

* Add Thumbnail optional

* Replace extra info for description on collection page

* Playlist card right below video on medium screen

* Allow editing private collections

* Add edit option to menus

* Allow deleting a public playlist but keeping a private version

* Add queue to Save menu, remove edit option from Builtin pages, show queue on playlists page

* Fix scroll to recent persisting on medium screen

* Fix adding to queue from menu

* Fixes for delete

* PublishList: delay mounting Items tab to prevent lock-up (#1783)

For a large list, the playlist publish form is unusable (super-slow typing) due to the entire list being mounted despite the tab is not active.
The full solution is still to paginate it, but for now, don't mount the tab until it is selected. Add a spinner to indicate something is loading. It's not prefect, but it's throwaway code anyway. At least we can fill in the fields properly now.

* Batch-resolve private collections (#1782)

* makeSelectClaimForClaimId --> selectClaimForClaimId

Move away from the problematic `makeSelect*`, especially in large loops.

* Batch-resolve private collections
1758

This alleviates the lock-up that is caused by large number of invidual resolves. There will still be some minor stutter due to the large DOM that React needs to handle -- that is logged in 1758 and will be handled separately.

At least the stutter is short (1-2s) and the app is still usable.
Private list items are being resolve individually, super slow if the list is large (>100). Published lists doesn't have this issue.
doFetchItemsInCollections contains most of the useful logic, but it isn't called for private/built-in lists because it's not an actual claim.
Tweaked doFetchItemsInCollections to handle private (UUID-based) collections.

* Use persisted state for floating player playlist card body
- I find it annoying being open everytime

* Fix removing edits from published playlist

* Fix scroll on mobile

* Allow going editing items from toast

* Fix ClaimShareButton

* Prevent edit/publish of builtin

* Fix async inside forEach

* Fix sync on queue edit

* Fix autoplayCountdown replay

* Fix deleting an item scrolling the playlist

* CreatedAt fixes

* Remove repost for now

* Anon publish fixes

* Fix mature case on floating

Co-authored-by: infinite-persistence <64950861+infinite-persistence@users.noreply.github.com>
2022-07-13 10:59:59 -03:00

349 lines
11 KiB
JavaScript

// @flow
const isProduction = process.env.NODE_ENV === 'production';
const channelNameMinLength = 1;
const claimIdMaxLength = 40;
// see https://spec.lbry.com/#urls
export const regexInvalidURI = /[ =&#:$@%?;/\\"<>%{}|^~[\]`\u{0000}-\u{0008}\u{000b}-\u{000c}\u{000e}-\u{001F}\u{D800}-\u{DFFF}\u{FFFE}-\u{FFFF}]/u;
export const regexAddress = /^(b|r)(?=[^0OIl]{32,33})[0-9A-Za-z]{32,33}$/;
const regexPartProtocol = '^((?:lbry://)?)';
const regexPartStreamOrChannelName = '([^:$#/]*)';
const regexPartModifierSeparator = '([:$#]?)([^/]*)';
const queryStringBreaker = '^([\\S]+)([?][\\S]*)';
const separateQuerystring = new RegExp(queryStringBreaker);
const MOD_SEQUENCE_SEPARATOR = '*';
const MOD_CLAIM_ID_SEPARATOR_OLD = '#';
const MOD_CLAIM_ID_SEPARATOR = ':';
const MOD_BID_POSITION_SEPARATOR = '$';
/**
* Parses a LBRY name into its component parts. Throws errors with user-friendly
* messages for invalid names.
*
* Returns a dictionary with keys:
* - path (string)
* - isChannel (boolean)
* - streamName (string, if present)
* - streamClaimId (string, if present)
* - channelName (string, if present)
* - channelClaimId (string, if present)
* - primaryClaimSequence (int, if present)
* - secondaryClaimSequence (int, if present)
* - primaryBidPosition (int, if present)
* - secondaryBidPosition (int, if present)
*/
export function parseURI(url: string, requireProto: boolean = false): LbryUrlObj {
// Break into components. Empty sub-matches are converted to null
const componentsRegex = new RegExp(
regexPartProtocol + // protocol
regexPartStreamOrChannelName + // stream or channel name (stops at the first separator or end)
regexPartModifierSeparator + // modifier separator, modifier (stops at the first path separator or end)
'(/?)' + // path separator, there should only be one (optional) slash to separate the stream and channel parts
regexPartStreamOrChannelName +
regexPartModifierSeparator
);
// chop off the querystring first
let QSStrippedURL, qs;
const qsRegexResult = separateQuerystring.exec(url);
if (qsRegexResult) {
[QSStrippedURL, qs] = qsRegexResult.slice(1).map((match) => match || null);
}
const cleanURL = QSStrippedURL || url;
const regexMatch = componentsRegex.exec(cleanURL) || [];
const [proto, ...rest] = regexMatch.slice(1).map((match) => match || null);
const path = rest.join('');
const [
streamNameOrChannelName,
primaryModSeparator,
primaryModValue,
pathSep, // eslint-disable-line no-unused-vars
possibleStreamName,
secondaryModSeparator,
secondaryModValue,
] = rest;
const searchParams = new URLSearchParams(qs || '');
const startTime = searchParams.get('t');
// Validate protocol
if (requireProto && !proto) {
throw new Error(__('LBRY URLs must include a protocol prefix (lbry://).'));
}
// Validate and process name
if (!streamNameOrChannelName) {
throw new Error(__('URL does not include name.'));
}
rest.forEach((urlPiece) => {
if (urlPiece && urlPiece.includes(' ')) {
throw new Error(__('URL can not include a space'));
}
});
const includesChannel = streamNameOrChannelName.startsWith('@');
const isChannel = streamNameOrChannelName.startsWith('@') && !possibleStreamName;
const channelName = includesChannel && streamNameOrChannelName.slice(1);
if (includesChannel) {
if (!channelName) {
throw new Error(__('No channel name after @.'));
}
if (channelName.length < channelNameMinLength) {
throw new Error(
__(`Channel names must be at least %channelNameMinLength% characters.`, {
channelNameMinLength,
})
);
}
}
// Validate and process modifier
const [primaryClaimId, primaryClaimSequence, primaryBidPosition, primaryPathHash] = parseURIModifier(
primaryModSeparator,
primaryModValue
);
const [secondaryClaimId, secondaryClaimSequence, secondaryBidPosition, secondaryPathHash] = parseURIModifier(
secondaryModSeparator,
secondaryModValue
);
const streamName = includesChannel ? possibleStreamName : streamNameOrChannelName;
const streamClaimId = includesChannel ? secondaryClaimId : primaryClaimId;
const channelClaimId = includesChannel && primaryClaimId;
const pathHash = primaryPathHash || secondaryPathHash;
return {
isChannel,
path,
...(streamName ? { streamName } : {}),
...(streamClaimId ? { streamClaimId } : {}),
...(channelName ? { channelName } : {}),
...(channelClaimId ? { channelClaimId } : {}),
...(primaryClaimSequence ? { primaryClaimSequence: parseInt(primaryClaimSequence, 10) } : {}),
...(secondaryClaimSequence ? { secondaryClaimSequence: parseInt(secondaryClaimSequence, 10) } : {}),
...(primaryBidPosition ? { primaryBidPosition: parseInt(primaryBidPosition, 10) } : {}),
...(secondaryBidPosition ? { secondaryBidPosition: parseInt(secondaryBidPosition, 10) } : {}),
...(startTime ? { startTime: parseInt(startTime, 10) } : {}),
...(pathHash ? { pathHash } : {}),
// The values below should not be used for new uses of parseURI
// They will not work properly with canonical_urls
claimName: streamNameOrChannelName,
claimId: primaryClaimId,
...(streamName ? { contentName: streamName } : {}),
...(qs ? { queryString: qs } : {}),
};
}
function parseURIModifier(modSeperator: ?string, modValue: ?string) {
let claimId;
let claimSequence;
let bidPosition;
let pathHash;
if (modSeperator) {
if (!modValue) {
throw new Error(__(`No modifier provided after separator %modSeperator%.`, { modSeperator }));
}
if (modSeperator === MOD_CLAIM_ID_SEPARATOR || MOD_CLAIM_ID_SEPARATOR_OLD) {
claimId = modValue;
} else if (modSeperator === MOD_SEQUENCE_SEPARATOR) {
claimSequence = modValue;
} else if (modSeperator === MOD_BID_POSITION_SEPARATOR) {
bidPosition = modValue;
}
}
if (claimId && (claimId.length > claimIdMaxLength || !claimId.match(/^[0-9a-f]+$/))) {
const hashIndex = claimId.indexOf('#');
if (hashIndex >= 0) {
pathHash = claimId.substring(hashIndex);
claimId = claimId.substring(0, hashIndex);
// As a pre-caution to catch future odd urls coming in,
// validate the new claimId length and characters again after stripping off the pathHash
[claimId] = parseURIModifier(modSeperator, claimId);
} else {
throw new Error(__(`Invalid claim ID %claimId%.`, { claimId }));
}
}
if (claimSequence && !claimSequence.match(/^-?[1-9][0-9]*$/)) {
throw new Error(__('Claim sequence must be a number.'));
}
if (bidPosition && !bidPosition.match(/^-?[1-9][0-9]*$/)) {
throw new Error(__('Bid position must be a number.'));
}
return [claimId, claimSequence, bidPosition, pathHash];
}
const errorHistory = [];
function logErrorOnce(err: string) {
if (!errorHistory.includes(err)) {
errorHistory.push(err);
console.error(err);
}
}
/**
* Takes an object in the same format returned by parse() and builds a URI.
*
* The channelName key will accept names with or without the @ prefix.
*/
export function buildURI(UrlObj: LbryUrlObj, includeProto: boolean = true, protoDefault: string = 'lbry://'): string {
const {
streamName,
streamClaimId,
channelName,
channelClaimId,
primaryClaimSequence,
primaryBidPosition,
secondaryClaimSequence,
secondaryBidPosition,
startTime,
...deprecatedParts
} = UrlObj;
const { claimId, claimName, contentName } = deprecatedParts;
// @ifndef IGNORE_BUILD_URI_WARNINGS
if (!isProduction) {
if (claimId) {
logErrorOnce("'claimId' should no longer be used. Use 'streamClaimId' or 'channelClaimId' instead");
}
if (claimName) {
logErrorOnce("'claimName' should no longer be used. Use 'streamClaimName' or 'channelClaimName' instead");
}
if (contentName) {
logErrorOnce("'contentName' should no longer be used. Use 'streamName' instead");
}
}
// @endif
if (!claimName && !channelName && !streamName) {
console.error("'claimName', 'channelName', and 'streamName' are all empty. One must be present to build a url.");
}
const formattedChannelName = channelName && (channelName.startsWith('@') ? channelName : `@${channelName}`);
const primaryClaimName = claimName || contentName || formattedChannelName || streamName;
const primaryClaimId = claimId || (formattedChannelName ? channelClaimId : streamClaimId);
const secondaryClaimName = (!claimName && contentName) || (formattedChannelName ? streamName : null);
const secondaryClaimId = secondaryClaimName && streamClaimId;
return (
(includeProto ? protoDefault : '') +
// primaryClaimName will always exist here because we throw above if there is no "name" value passed in
// $FlowFixMe
primaryClaimName +
(primaryClaimId ? `#${primaryClaimId}` : '') +
(primaryClaimSequence ? `:${primaryClaimSequence}` : '') +
(primaryBidPosition ? `${primaryBidPosition}` : '') +
(secondaryClaimName ? `/${secondaryClaimName}` : '') +
(secondaryClaimId ? `#${secondaryClaimId}` : '') +
(secondaryClaimSequence ? `:${secondaryClaimSequence}` : '') +
(secondaryBidPosition ? `${secondaryBidPosition}` : '') +
(startTime ? `?t=${startTime}` : '')
);
}
/* Takes a parseable LBRY URL and converts it to standard, canonical format */
export function normalizeURI(URL: string) {
const {
streamName,
streamClaimId,
channelName,
channelClaimId,
primaryClaimSequence,
primaryBidPosition,
secondaryClaimSequence,
secondaryBidPosition,
startTime,
} = parseURI(URL);
return buildURI({
streamName,
streamClaimId,
channelName,
channelClaimId,
primaryClaimSequence,
primaryBidPosition,
secondaryClaimSequence,
secondaryBidPosition,
startTime,
});
}
export function isURIValid(URL: string, normalize: boolean = true): boolean {
try {
parseURI(normalize ? normalizeURI(URL) : URL);
} catch (error) {
return false;
}
return true;
}
export function isNameValid(claimName: string) {
return !regexInvalidURI.test(claimName);
}
export function isURIClaimable(URL: string) {
let parts;
try {
parts = parseURI(normalizeURI(URL));
} catch (error) {
return false;
}
return parts && parts.streamName && !parts.streamClaimId && !parts.isChannel;
}
export function convertToShareLink(URL: string) {
const {
streamName,
streamClaimId,
channelName,
channelClaimId,
primaryBidPosition,
primaryClaimSequence,
secondaryBidPosition,
secondaryClaimSequence,
} = parseURI(URL);
return buildURI(
{
streamName,
streamClaimId,
channelName,
channelClaimId,
primaryBidPosition,
primaryClaimSequence,
secondaryBidPosition,
secondaryClaimSequence,
},
true,
'https://open.lbry.com/'
);
}
export function splitBySeparator(uri: string) {
const protocolLength = 7;
return uri.startsWith('lbry://') ? uri.slice(protocolLength).split(/[#:*]/) : uri.split(/#:\*\$/);
}
export function isURIEqual(uriA: string, uriB: string) {
const a = uriA && uriA.replace(/:/g, '#');
const b = uriB && uriB.replace(/:/g, '#');
return a === b;
}
export function parseName(newName: string) {
let INVALID_URI_CHARS = new RegExp(regexInvalidURI, 'gu');
return newName.replace(INVALID_URI_CHARS, '-');
}