Membership subscriptions (#812)

Enter: Odysee Premium.
This commit is contained in:
mayeaux 2022-03-09 19:05:37 +01:00 committed by GitHub
parent a34e07e970
commit fb3a73d8a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 3032 additions and 347 deletions

View file

@ -1,7 +1,7 @@
// @flow // @flow
import { selectClaimIdForUri } from 'redux/selectors/claims'; import { selectClaimIdForUri } from 'redux/selectors/claims';
type State = { claims: any, stats: any }; type State = { claims: any, stats: any, user: User };
const selectState = (state: State) => state.stats || {}; const selectState = (state: State) => state.stats || {};
export const selectViewCount = (state: State) => selectState(state).viewCountById; export const selectViewCount = (state: State) => selectState(state).viewCountById;

1
flow-typed/user.js vendored
View file

@ -32,4 +32,5 @@ declare type User = {
odysee_live_enabled: boolean, odysee_live_enabled: boolean,
odysee_live_disabled: boolean, odysee_live_disabled: boolean,
global_mod: boolean, global_mod: boolean,
odyseeMembershipsPerClaimIds: ?{},
}; };

View file

@ -11,7 +11,7 @@ import { doResolveUris } from 'redux/actions/claims';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings'; import { selectClientSetting, selectShowMatureContent } from 'redux/selectors/settings';
import { doFetchChannelLiveStatus } from 'redux/actions/livestream'; import { doFetchChannelLiveStatus } from 'redux/actions/livestream';
import { selectActiveLivestreamForChannel, selectActiveLivestreamInitialized } from 'redux/selectors/livestream'; import { selectActiveLivestreamForChannel, selectActiveLivestreamInitialized } from 'redux/selectors/livestream';
@ -32,11 +32,11 @@ const select = (state, props) => {
channelIsMine: selectClaimIsMine(state, claim), channelIsMine: selectClaimIsMine(state, claim),
channelIsBlocked: makeSelectChannelIsMuted(props.uri)(state), channelIsBlocked: makeSelectChannelIsMuted(props.uri)(state),
claim, claim,
isAuthenticated: selectUserVerifiedEmail(state),
showMature: selectShowMatureContent(state), showMature: selectShowMatureContent(state),
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT), tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId), activeLivestreamForChannel: selectActiveLivestreamForChannel(state, channelClaimId),
activeLivestreamInitialized: selectActiveLivestreamInitialized(state), activeLivestreamInitialized: selectActiveLivestreamInitialized(state),
userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}; };
}; };

View file

@ -39,6 +39,7 @@ type Props = {
doFetchChannelLiveStatus: (string) => void, doFetchChannelLiveStatus: (string) => void,
activeLivestreamForChannel: any, activeLivestreamForChannel: any,
activeLivestreamInitialized: boolean, activeLivestreamInitialized: boolean,
userHasPremiumPlus: boolean,
}; };
function ChannelContent(props: Props) { function ChannelContent(props: Props) {
@ -49,7 +50,6 @@ function ChannelContent(props: Props) {
channelIsBlocked, channelIsBlocked,
channelIsBlackListed, channelIsBlackListed,
claim, claim,
isAuthenticated,
defaultPageSize = CS.PAGE_SIZE, defaultPageSize = CS.PAGE_SIZE,
defaultInfiniteScroll = true, defaultInfiniteScroll = true,
showMature, showMature,
@ -61,8 +61,10 @@ function ChannelContent(props: Props) {
doFetchChannelLiveStatus, doFetchChannelLiveStatus,
activeLivestreamForChannel, activeLivestreamForChannel,
activeLivestreamInitialized, activeLivestreamInitialized,
userHasPremiumPlus,
} = props; } = props;
// const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0; // const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
const claimsInChannel = 9999; const claimsInChannel = 9999;
const [searchQuery, setSearchQuery] = React.useState(''); const [searchQuery, setSearchQuery] = React.useState('');
const [isSearching, setIsSearching] = React.useState(false); const [isSearching, setIsSearching] = React.useState(false);
@ -160,7 +162,7 @@ function ChannelContent(props: Props) {
defaultOrderBy={CS.ORDER_BY_NEW} defaultOrderBy={CS.ORDER_BY_NEW}
pageSize={defaultPageSize} pageSize={defaultPageSize}
infiniteScroll={defaultInfiniteScroll} infiniteScroll={defaultInfiniteScroll}
injectedItem={SHOW_ADS && !isAuthenticated && { node: <Ads type="video" tileLayout={tileLayout} small /> }} injectedItem={SHOW_ADS && !userHasPremiumPlus && { node: <Ads type="video" tileLayout={tileLayout} small /> }}
meta={ meta={
showFilters && ( showFilters && (
<Form onSubmit={() => {}} className="wunderbar--inline"> <Form onSubmit={() => {}} className="wunderbar--inline">

View file

@ -1,16 +1,24 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectMyChannelClaims } from 'redux/selectors/claims'; import { selectMyChannelClaims, selectClaimsByUri, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app'; import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { doSetActiveChannel, doSetIncognito } from 'redux/actions/app'; import { doSetActiveChannel, doSetIncognito } from 'redux/actions/app';
import { doFetchUserMemberships } from 'redux/actions/user';
import ChannelSelector from './view'; import ChannelSelector from './view';
const select = (state) => ({ const select = (state, props) => {
channels: selectMyChannelClaims(state), const activeChannelClaim = selectActiveChannelClaim(state);
activeChannelClaim: selectActiveChannelClaim(state),
incognito: selectIncognito(state), return {
}); channels: selectMyChannelClaims(state),
activeChannelClaim,
incognito: selectIncognito(state),
odyseeMembershipByUri: (uri) => selectOdyseeMembershipForUri(state, uri),
claimsByUri: selectClaimsByUri(state),
};
};
export default connect(select, { export default connect(select, {
doSetActiveChannel, doSetActiveChannel,
doSetIncognito, doSetIncognito,
doFetchUserMemberships,
})(ChannelSelector); })(ChannelSelector);

View file

@ -8,6 +8,8 @@ import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import ChannelTitle from 'component/channelTitle'; import ChannelTitle from 'component/channelTitle';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
selectedChannelUrl: string, // currently selected channel selectedChannelUrl: string, // currently selected channel
@ -18,20 +20,32 @@ type Props = {
doSetActiveChannel: (string) => void, doSetActiveChannel: (string) => void,
incognito: boolean, incognito: boolean,
doSetIncognito: (boolean) => void, doSetIncognito: (boolean) => void,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
odyseeMembershipByUri: (uri: string) => string,
}; };
type ListItemProps = { type ListItemProps = {
uri: string, uri: string,
isSelected?: boolean, isSelected?: boolean,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
odyseeMembershipByUri: (uri: string) => string,
}; };
function ChannelListItem(props: ListItemProps) { function ChannelListItem(props: ListItemProps) {
const { uri, isSelected = false } = props; const { uri, isSelected = false, claimsByUri, doFetchUserMemberships, odyseeMembershipByUri } = props;
const membership = odyseeMembershipByUri(uri);
const shouldFetchUserMemberships = true;
useGetUserMemberships(shouldFetchUserMemberships, [uri], claimsByUri, doFetchUserMemberships, [uri]);
return ( return (
<div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}> <div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}>
<ChannelThumbnail uri={uri} hideStakedIndicator xsmall noLazyLoad /> <ChannelThumbnail uri={uri} hideStakedIndicator xsmall noLazyLoad />
<ChannelTitle uri={uri} /> <ChannelTitle uri={uri} />
<PremiumBadge membership={membership} />
{isSelected && <Icon icon={ICONS.DOWN} />} {isSelected && <Icon icon={ICONS.DOWN} />}
</div> </div>
); );
@ -52,11 +66,23 @@ function IncognitoSelector(props: IncognitoSelectorProps) {
} }
function ChannelSelector(props: Props) { function ChannelSelector(props: Props) {
const { channels, activeChannelClaim, doSetActiveChannel, hideAnon = false, incognito, doSetIncognito } = props; const {
channels,
activeChannelClaim,
doSetActiveChannel,
hideAnon = false,
incognito,
doSetIncognito,
odyseeMembershipByUri,
claimsByUri,
doFetchUserMemberships,
} = props;
const { const {
push, push,
location: { pathname }, location: { pathname },
} = useHistory(); } = useHistory();
const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url; const activeChannelUrl = activeChannelClaim && activeChannelClaim.permanent_url;
function handleChannelSelect(channelClaim) { function handleChannelSelect(channelClaim) {
@ -71,14 +97,26 @@ function ChannelSelector(props: Props) {
{(incognito && !hideAnon) || !activeChannelUrl ? ( {(incognito && !hideAnon) || !activeChannelUrl ? (
<IncognitoSelector isSelected /> <IncognitoSelector isSelected />
) : ( ) : (
<ChannelListItem uri={activeChannelUrl} isSelected /> <ChannelListItem
odyseeMembershipByUri={odyseeMembershipByUri}
uri={activeChannelUrl}
isSelected
claimsByUri={claimsByUri}
doFetchUserMemberships={doFetchUserMemberships}
/>
)} )}
</MenuButton> </MenuButton>
<MenuList className="menu__list channel__list"> <MenuList className="menu__list channel__list">
{channels && {channels &&
channels.map((channel) => ( channels.map((channel) => (
<MenuItem key={channel.permanent_url} onSelect={() => handleChannelSelect(channel)}> <MenuItem key={channel.permanent_url} onSelect={() => handleChannelSelect(channel)}>
<ChannelListItem uri={channel.permanent_url} /> <ChannelListItem
odyseeMembershipByUri={odyseeMembershipByUri}
uri={channel.permanent_url}
claimsByUri={claimsByUri}
doFetchUserMemberships={doFetchUserMemberships}
/>
</MenuItem> </MenuItem>
))} ))}
{!hideAnon && ( {!hideAnon && (

View file

@ -1,14 +1,24 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectThumbnailForUri, selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims'; import {
selectThumbnailForUri,
selectClaimForUri,
selectIsUriResolving,
selectClaimsByUri,
selectOdyseeMembershipForUri,
} from 'redux/selectors/claims';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { doFetchUserMemberships } from 'redux/actions/user';
import ChannelThumbnail from './view'; import ChannelThumbnail from './view';
const select = (state, props) => ({ const select = (state, props) => ({
thumbnail: selectThumbnailForUri(state, props.uri), thumbnail: selectThumbnailForUri(state, props.uri),
claim: selectClaimForUri(state, props.uri), claim: selectClaimForUri(state, props.uri),
isResolving: selectIsUriResolving(state, props.uri), isResolving: selectIsUriResolving(state, props.uri),
odyseeMembership: selectOdyseeMembershipForUri(state, props.uri),
claimsByUri: selectClaimsByUri(state),
}); });
export default connect(select, { export default connect(select, {
doResolveUri, doResolveUri,
doFetchUserMemberships,
})(ChannelThumbnail); })(ChannelThumbnail);

View file

@ -4,9 +4,10 @@ import { parseURI } from 'util/lbryURI';
import classnames from 'classnames'; import classnames from 'classnames';
import Gerbil from './gerbil.png'; import Gerbil from './gerbil.png';
import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper'; import FreezeframeWrapper from 'component/fileThumbnail/FreezeframeWrapper';
import ChannelStakedIndicator from 'component/channelStakedIndicator';
import OptimizedImage from 'component/optimizedImage'; import OptimizedImage from 'component/optimizedImage';
import { AVATAR_DEFAULT } from 'config'; import { AVATAR_DEFAULT } from 'config';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
thumbnail: ?string, thumbnail: ?string,
@ -26,6 +27,12 @@ type Props = {
noOptimization?: boolean, noOptimization?: boolean,
setThumbUploadError: (boolean) => void, setThumbUploadError: (boolean) => void,
ThumbUploadError: boolean, ThumbUploadError: boolean,
claimsByUri: { [string]: any },
odyseeMembership: string,
doFetchUserMemberships: (claimIdCsv: string) => void,
showMemberBadge?: boolean,
isChannel?: boolean,
checkMembership: boolean,
}; };
function ChannelThumbnail(props: Props) { function ChannelThumbnail(props: Props) {
@ -42,10 +49,15 @@ function ChannelThumbnail(props: Props) {
doResolveUri, doResolveUri,
isResolving, isResolving,
noLazyLoad, noLazyLoad,
hideStakedIndicator = false,
hideTooltip, hideTooltip,
setThumbUploadError, setThumbUploadError,
ThumbUploadError, ThumbUploadError,
claimsByUri,
odyseeMembership,
doFetchUserMemberships,
showMemberBadge,
isChannel,
checkMembership = true,
} = props; } = props;
const [thumbLoadError, setThumbLoadError] = React.useState(ThumbUploadError); const [thumbLoadError, setThumbLoadError] = React.useState(ThumbUploadError);
const shouldResolve = !isResolving && claim === undefined; const shouldResolve = !isResolving && claim === undefined;
@ -56,6 +68,16 @@ function ChannelThumbnail(props: Props) {
const isGif = channelThumbnail && channelThumbnail.endsWith('gif'); const isGif = channelThumbnail && channelThumbnail.endsWith('gif');
const showThumb = (!obscure && !!thumbnail) || thumbnailPreview; const showThumb = (!obscure && !!thumbnail) || thumbnailPreview;
const badgeProps = {
membership: odyseeMembership,
linkPage: isChannel,
placement: isChannel ? 'bottom' : undefined,
hideTooltip,
className: isChannel ? 'profile-badge__tooltip' : undefined,
};
useGetUserMemberships(checkMembership, [uri], claimsByUri, doFetchUserMemberships, [uri]);
// Generate a random color class based on the first letter of the channel name // Generate a random color class based on the first letter of the channel name
const { channelName } = parseURI(uri); const { channelName } = parseURI(uri);
let initializer; let initializer;
@ -76,7 +98,7 @@ function ChannelThumbnail(props: Props) {
if (isGif && !allowGifs) { if (isGif && !allowGifs) {
return ( return (
<FreezeframeWrapper src={channelThumbnail} className={classnames('channel-thumbnail', className)}> <FreezeframeWrapper src={channelThumbnail} className={classnames('channel-thumbnail', className)}>
{!hideStakedIndicator && <ChannelStakedIndicator uri={uri} claim={claim} hideTooltip={hideTooltip} />} {showMemberBadge && <PremiumBadge {...badgeProps} />}
</FreezeframeWrapper> </FreezeframeWrapper>
); );
} }
@ -103,7 +125,7 @@ function ChannelThumbnail(props: Props) {
} }
}} }}
/> />
{!hideStakedIndicator && <ChannelStakedIndicator uri={uri} claim={claim} hideTooltip={hideTooltip} />} {showMemberBadge && <PremiumBadge {...badgeProps} />}
</div> </div>
); );
} }

View file

@ -58,6 +58,7 @@ type Props = {
showEdit?: boolean, showEdit?: boolean,
droppableProvided?: any, droppableProvided?: any,
unavailableUris?: Array<string>, unavailableUris?: Array<string>,
showMemberBadge?: boolean,
}; };
export default function ClaimList(props: Props) { export default function ClaimList(props: Props) {
@ -96,6 +97,7 @@ export default function ClaimList(props: Props) {
showEdit, showEdit,
droppableProvided, droppableProvided,
unavailableUris, unavailableUris,
showMemberBadge,
} = props; } = props;
const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW); const [currentSort, setCurrentSort] = usePersistedState(persistedStorageKey, SORT_NEW);
@ -196,6 +198,7 @@ export default function ClaimList(props: Props) {
showEdit={showEdit} showEdit={showEdit}
dragHandleProps={draggableProvided && draggableProvided.dragHandleProps} dragHandleProps={draggableProvided && draggableProvided.dragHandleProps}
unavailableUris={unavailableUris} unavailableUris={unavailableUris}
showMemberBadge={showMemberBadge}
/> />
); );

View file

@ -9,6 +9,7 @@ import { doClaimSearch } from 'redux/actions/claims';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { selectMutedChannels } from 'redux/selectors/blocked'; import { selectMutedChannels } from 'redux/selectors/blocked';
import { doFetchUserMemberships } from 'redux/actions/user';
import { selectClientSetting, selectShowMatureContent, selectLanguage } from 'redux/selectors/settings'; import { selectClientSetting, selectShowMatureContent, selectLanguage } from 'redux/selectors/settings';
import { selectModerationBlockList } from 'redux/selectors/comments'; import { selectModerationBlockList } from 'redux/selectors/comments';
import ClaimListDiscover from './view'; import ClaimListDiscover from './view';
@ -31,6 +32,7 @@ const select = (state, props) => ({
const perform = { const perform = {
doClaimSearch, doClaimSearch,
doFetchViewCount, doFetchViewCount,
doFetchUserMemberships,
}; };
export default connect(select, perform)(ClaimListDiscover); export default connect(select, perform)(ClaimListDiscover);

View file

@ -19,6 +19,7 @@ import LangFilterIndicator from 'component/langFilterIndicator';
import ClaimListHeader from 'component/claimListHeader'; import ClaimListHeader from 'component/claimListHeader';
import useFetchViewCount from 'effects/use-fetch-view-count'; import useFetchViewCount from 'effects/use-fetch-view-count';
import { useIsLargeScreen } from 'effects/use-screensize'; import { useIsLargeScreen } from 'effects/use-screensize';
import useGetUserMemberships from 'effects/use-get-user-memberships';
type Props = { type Props = {
uris: Array<string>, uris: Array<string>,
@ -98,6 +99,7 @@ type Props = {
// --- perform --- // --- perform ---
doClaimSearch: ({}) => void, doClaimSearch: ({}) => void,
doFetchViewCount: (claimIdCsv: string) => void, doFetchViewCount: (claimIdCsv: string) => void,
doFetchUserMemberships: (claimIdCsv: string) => void,
hideLayoutButton?: boolean, hideLayoutButton?: boolean,
loadedCallback?: (number) => void, loadedCallback?: (number) => void,
@ -177,6 +179,7 @@ function ClaimListDiscover(props: Props) {
maxClaimRender, maxClaimRender,
useSkeletonScreen = true, useSkeletonScreen = true,
excludeUris = [], excludeUris = [],
doFetchUserMemberships,
swipeLayout = false, swipeLayout = false,
} = props; } = props;
const didNavigateForward = history.action === 'PUSH'; const didNavigateForward = history.action === 'PUSH';
@ -608,9 +611,14 @@ function ClaimListDiscover(props: Props) {
// ************************************************************************** // **************************************************************************
// ************************************************************************** // **************************************************************************
useFetchViewCount(fetchViewCount, renderUris, claimsByUri, doFetchViewCount); useFetchViewCount(fetchViewCount, renderUris, claimsByUri, doFetchViewCount);
const shouldFetchUserMemberships = true;
const arrayOfContentUris = renderUris;
const convertClaimUrlsToIds = claimsByUri;
useGetUserMemberships(shouldFetchUserMemberships, arrayOfContentUris, convertClaimUrlsToIds, doFetchUserMemberships);
React.useEffect(() => { React.useEffect(() => {
if (shouldPerformSearch) { if (shouldPerformSearch) {
const searchOptions = JSON.parse(optionsStringForEffect); const searchOptions = JSON.parse(optionsStringForEffect);

View file

@ -90,6 +90,7 @@ type Props = {
showEdit?: boolean, showEdit?: boolean,
dragHandleProps?: any, dragHandleProps?: any,
unavailableUris?: Array<string>, unavailableUris?: Array<string>,
showMemberBadge?: boolean,
}; };
const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => { const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
@ -152,6 +153,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
showEdit, showEdit,
dragHandleProps, dragHandleProps,
unavailableUris, unavailableUris,
showMemberBadge,
} = props; } = props;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -366,7 +368,7 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
{isChannelUri && claim ? ( {isChannelUri && claim ? (
<UriIndicator focusable={false} uri={uri} link> <UriIndicator focusable={false} uri={uri} link>
<ChannelThumbnail uri={uri} small={type === 'inline'} /> <ChannelThumbnail uri={uri} small={type === 'inline'} showMemberBadge={showMemberBadge} checkMembership={false} />
</UriIndicator> </UriIndicator>
) : ( ) : (
<> <>
@ -411,11 +413,16 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
{!isChannelUri && signingChannel && ( {!isChannelUri && signingChannel && (
<div className="claim-preview__channel-staked"> <div className="claim-preview__channel-staked">
<UriIndicator focusable={false} uri={uri} link hideAnonymous> <UriIndicator focusable={false} uri={uri} link hideAnonymous>
<ChannelThumbnail uri={signingChannel.permanent_url} xsmall /> <ChannelThumbnail uri={signingChannel.permanent_url} xsmall showMemberBadge={showMemberBadge} checkMembership={false} />
</UriIndicator> </UriIndicator>
</div> </div>
)} )}
<ClaimPreviewSubtitle uri={uri} type={type} showAtSign={isChannelUri} /> <ClaimPreviewSubtitle
uri={uri}
type={type}
showAtSign={isChannelUri}
showMemberBadge={!showMemberBadge}
/>
{(pending || !!reflectingProgress) && <PublishPending uri={uri} />} {(pending || !!reflectingProgress) && <PublishPending uri={uri} />}
{channelSubscribers} {channelSubscribers}
</div> </div>

View file

@ -21,11 +21,24 @@ type Props = {
lang: string, lang: string,
fetchSubCount: (string) => void, fetchSubCount: (string) => void,
subCount: number, subCount: number,
showMemberBadge?: boolean,
}; };
// previews used in channel overview and homepage (and other places?) // previews used in channel overview and homepage (and other places?)
function ClaimPreviewSubtitle(props: Props) { function ClaimPreviewSubtitle(props: Props) {
const { pending, uri, claim, type, beginPublish, isLivestream, fetchSubCount, subCount, showAtSign, lang } = props; const {
pending,
uri,
claim,
type,
beginPublish,
isLivestream,
fetchSubCount,
subCount,
showAtSign,
lang,
showMemberBadge,
} = props;
const isChannel = claim && claim.value_type === 'channel'; const isChannel = claim && claim.value_type === 'channel';
const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0; const claimsInChannel = (claim && claim.meta.claims_in_channel) || 0;
@ -47,7 +60,7 @@ function ClaimPreviewSubtitle(props: Props) {
<div className="media__subtitle"> <div className="media__subtitle">
{claim ? ( {claim ? (
<React.Fragment> <React.Fragment>
<UriIndicator uri={uri} showAtSign={showAtSign} link />{' '} <UriIndicator uri={uri} showAtSign={showAtSign} showMemberBadge={showMemberBadge} link />{' '}
{!pending && claim && ( {!pending && claim && (
<> <>
{isChannel && type !== 'inline' && ( {isChannel && type !== 'inline' && (

View file

@ -243,7 +243,7 @@ function ClaimPreviewTile(props: Props) {
) : ( ) : (
<React.Fragment> <React.Fragment>
<UriIndicator focusable={false} uri={uri} link hideAnonymous> <UriIndicator focusable={false} uri={uri} link hideAnonymous>
<ChannelThumbnail uri={channelUri} xsmall /> <ChannelThumbnail uri={channelUri} xsmall checkMembership={false} />
</UriIndicator> </UriIndicator>
<div className="claim-tile__about"> <div className="claim-tile__about">

View file

@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
import { selectClaimSearchByQuery, selectFetchingClaimSearchByQuery, selectClaimsByUri } from 'redux/selectors/claims'; import { selectClaimSearchByQuery, selectFetchingClaimSearchByQuery, selectClaimsByUri } from 'redux/selectors/claims';
import { doClaimSearch } from 'redux/actions/claims'; import { doClaimSearch } from 'redux/actions/claims';
import { doFetchUserMemberships } from 'redux/actions/user';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { MATURE_TAGS } from 'constants/tags'; import { MATURE_TAGS } from 'constants/tags';
import { doFetchViewCount } from 'lbryinc'; import { doFetchViewCount } from 'lbryinc';
@ -37,6 +38,7 @@ const select = (state, props) => {
const perform = { const perform = {
doClaimSearch, doClaimSearch,
doFetchViewCount, doFetchViewCount,
doFetchUserMemberships,
}; };
export default withRouter(connect(select, perform)(ClaimListDiscover)); export default withRouter(connect(select, perform)(ClaimListDiscover));

View file

@ -4,6 +4,7 @@ import React from 'react';
import ClaimPreviewTile from 'component/claimPreviewTile'; import ClaimPreviewTile from 'component/claimPreviewTile';
import useFetchViewCount from 'effects/use-fetch-view-count'; import useFetchViewCount from 'effects/use-fetch-view-count';
import useLastVisibleItem from 'effects/use-last-visible-item'; import useLastVisibleItem from 'effects/use-last-visible-item';
import useGetUserMemberships from 'effects/use-get-user-memberships';
function urisEqual(prev: ?Array<string>, next: ?Array<string>) { function urisEqual(prev: ?Array<string>, next: ?Array<string>) {
if (!prev || !next) { if (!prev || !next) {
@ -56,6 +57,7 @@ type Props = {
// --- perform --- // --- perform ---
doClaimSearch: ({}) => void, doClaimSearch: ({}) => void,
doFetchViewCount: (claimIdCsv: string) => void, doFetchViewCount: (claimIdCsv: string) => void,
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
function ClaimTilesDiscover(props: Props) { function ClaimTilesDiscover(props: Props) {
@ -74,6 +76,7 @@ function ClaimTilesDiscover(props: Props) {
doFetchViewCount, doFetchViewCount,
pageSize = 8, pageSize = 8,
optionsStringified, optionsStringified,
doFetchUserMemberships,
} = props; } = props;
// reference to the claim-grid // reference to the claim-grid
@ -117,6 +120,10 @@ function ClaimTilesDiscover(props: Props) {
// populate the view counts for the current claim uris // populate the view counts for the current claim uris
useFetchViewCount(fetchViewCount, uris, claimsByUri, doFetchViewCount); useFetchViewCount(fetchViewCount, uris, claimsByUri, doFetchViewCount);
const shouldFetchUserMemberships = true;
useGetUserMemberships(shouldFetchUserMemberships, uris, claimsByUri, doFetchUserMemberships);
// Run `doClaimSearch` // Run `doClaimSearch`
React.useEffect(() => { React.useEffect(() => {
if (shouldPerformSearch) { if (shouldPerformSearch) {

View file

@ -5,12 +5,12 @@ import {
selectThumbnailForUri, selectThumbnailForUri,
selectHasChannels, selectHasChannels,
selectMyClaimIdsRaw, selectMyClaimIdsRaw,
selectOdyseeMembershipForUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { doCommentUpdate, doCommentList } from 'redux/actions/comments'; import { doCommentUpdate, doCommentList } from 'redux/actions/comments';
import { makeSelectChannelIsMuted } from 'redux/selectors/blocked'; import { makeSelectChannelIsMuted } from 'redux/selectors/blocked';
import { doToast } from 'redux/actions/notifications'; import { doToast } from 'redux/actions/notifications';
import { doClearPlayingUri } from 'redux/actions/content'; import { doClearPlayingUri } from 'redux/actions/content';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { import {
selectLinkedCommentAncestors, selectLinkedCommentAncestors,
selectOthersReactsForComment, selectOthersReactsForComment,
@ -18,6 +18,9 @@ import {
} from 'redux/selectors/comments'; } from 'redux/selectors/comments';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { selectPlayingUri } from 'redux/selectors/content'; import { selectPlayingUri } from 'redux/selectors/content';
import {
selectUserVerifiedEmail,
} from 'redux/selectors/user';
import Comment from './view'; import Comment from './view';
const select = (state, props) => { const select = (state, props) => {
@ -33,7 +36,7 @@ const select = (state, props) => {
claim: makeSelectClaimForUri(uri)(state), claim: makeSelectClaimForUri(uri)(state),
thumbnail: channel_url && selectThumbnailForUri(state, channel_url), thumbnail: channel_url && selectThumbnailForUri(state, channel_url),
channelIsBlocked: channel_url && makeSelectChannelIsMuted(channel_url)(state), channelIsBlocked: channel_url && makeSelectChannelIsMuted(channel_url)(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true, commentingEnabled: Boolean(selectUserVerifiedEmail(state)),
othersReacts: selectOthersReactsForComment(state, reactionKey), othersReacts: selectOthersReactsForComment(state, reactionKey),
activeChannelClaim, activeChannelClaim,
hasChannels: selectHasChannels(state), hasChannels: selectHasChannels(state),
@ -41,6 +44,7 @@ const select = (state, props) => {
stakedLevel: selectStakedLevelForChannelUri(state, channel_url), stakedLevel: selectStakedLevelForChannelUri(state, channel_url),
linkedCommentAncestors: selectLinkedCommentAncestors(state), linkedCommentAncestors: selectLinkedCommentAncestors(state),
totalReplyPages: makeSelectTotalReplyPagesForParentId(comment_id)(state), totalReplyPages: makeSelectTotalReplyPagesForParentId(comment_id)(state),
selectOdyseeMembershipForUri: channel_url && selectOdyseeMembershipForUri(state, channel_url),
}; };
}; };

View file

@ -30,6 +30,7 @@ import OptimizedImage from 'component/optimizedImage';
import { getChannelFromClaim } from 'util/claim'; import { getChannelFromClaim } from 'util/claim';
import { parseSticker } from 'util/comments'; import { parseSticker } from 'util/comments';
import { useIsMobile } from 'effects/use-screensize'; import { useIsMobile } from 'effects/use-screensize';
import PremiumBadge from 'component/common/premium-badge';
const AUTO_EXPAND_ALL_REPLIES = false; const AUTO_EXPAND_ALL_REPLIES = false;
@ -64,6 +65,7 @@ type Props = {
supportDisabled: boolean, supportDisabled: boolean,
setQuickReply: (any) => void, setQuickReply: (any) => void,
quickReply: any, quickReply: any,
selectOdyseeMembershipForUri: string,
}; };
const LENGTH_TO_COLLAPSE = 300; const LENGTH_TO_COLLAPSE = 300;
@ -93,6 +95,7 @@ function CommentView(props: Props) {
supportDisabled, supportDisabled,
setQuickReply, setQuickReply,
quickReply, quickReply,
selectOdyseeMembershipForUri,
} = props; } = props;
const { const {
@ -256,18 +259,15 @@ function CommentView(props: Props) {
> >
<div className="comment__thumbnail-wrapper"> <div className="comment__thumbnail-wrapper">
{authorUri ? ( {authorUri ? (
<ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} xsmall className="comment__author-thumbnail" /> <ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} xsmall className="comment__author-thumbnail" checkMembership={false} />
) : ( ) : (
<ChannelThumbnail xsmall className="comment__author-thumbnail" /> <ChannelThumbnail xsmall className="comment__author-thumbnail" checkMembership={false} />
)} )}
</div> </div>
<div className="comment__body-container"> <div className="comment__body-container">
<div className="comment__meta"> <div className="comment__meta">
<div className="comment__meta-information"> <div className="comment__meta-information">
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} />}
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
{!author ? ( {!author ? (
<span className="comment__author">{__('Anonymous')}</span> <span className="comment__author">{__('Anonymous')}</span>
) : ( ) : (
@ -277,9 +277,13 @@ function CommentView(props: Props) {
})} })}
link link
uri={authorUri} uri={authorUri}
comment
showAtSign showAtSign
/> />
)} )}
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_ADMIN} />}
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} />}
<PremiumBadge membership={selectOdyseeMembershipForUri} linkPage />
<Button <Button
className="comment__time" className="comment__time"
onClick={handleTimeClick} onClick={handleTimeClick}
@ -358,6 +362,7 @@ function CommentView(props: Props) {
promptLinks promptLinks
parentCommentId={commentId} parentCommentId={commentId}
stakedLevel={stakedLevel} stakedLevel={stakedLevel}
hasMembership={selectOdyseeMembershipForUri}
/> />
</Expandable> </Expandable>
) : ( ) : (
@ -366,6 +371,7 @@ function CommentView(props: Props) {
promptLinks promptLinks
parentCommentId={commentId} parentCommentId={commentId}
stakedLevel={stakedLevel} stakedLevel={stakedLevel}
hasMembership={selectOdyseeMembershipForUri}
/> />
)} )}
</div> </div>

View file

@ -1,5 +1,9 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectClaimForUri, selectClaimIsMine, selectFetchingMyChannels } from 'redux/selectors/claims'; import { selectClaimForUri,
selectClaimIsMine,
selectFetchingMyChannels,
selectClaimsByUri,
} from 'redux/selectors/claims';
import { import {
selectTopLevelCommentsForUri, selectTopLevelCommentsForUri,
makeSelectTopLevelTotalPagesForUri, makeSelectTopLevelTotalPagesForUri,
@ -16,6 +20,7 @@ import {
import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from 'redux/actions/comments'; import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from 'redux/actions/comments';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim } from 'redux/selectors/app';
import { getChannelIdFromClaim } from 'util/claim'; import { getChannelIdFromClaim } from 'util/claim';
import { doFetchUserMemberships } from 'redux/actions/user';
import CommentsList from './view'; import CommentsList from './view';
const select = (state, props) => { const select = (state, props) => {
@ -41,6 +46,7 @@ const select = (state, props) => {
myReactsByCommentId: selectMyReacts(state), myReactsByCommentId: selectMyReacts(state),
othersReactsById: selectOthersReacts(state), othersReactsById: selectOthersReacts(state),
activeChannelId: activeChannelClaim && activeChannelClaim.claim_id, activeChannelId: activeChannelClaim && activeChannelClaim.claim_id,
claimsByUri: selectClaimsByUri(state),
}; };
}; };
@ -49,6 +55,7 @@ const perform = {
fetchComment: doCommentById, fetchComment: doCommentById,
fetchReacts: doCommentReactList, fetchReacts: doCommentReactList,
resetComments: doCommentReset, resetComments: doCommentReset,
doFetchUserMemberships,
}; };
export default connect(select, perform)(CommentsList); export default connect(select, perform)(CommentsList);

View file

@ -15,6 +15,7 @@ import Empty from 'component/common/empty';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import Spinner from 'component/spinner'; import Spinner from 'component/spinner';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import useGetUserMemberships from 'effects/use-get-user-memberships';
const DEBOUNCE_SCROLL_HANDLER_MS = 200; const DEBOUNCE_SCROLL_HANDLER_MS = 200;
@ -50,6 +51,8 @@ type Props = {
fetchComment: (commentId: string) => void, fetchComment: (commentId: string) => void,
fetchReacts: (commentIds: Array<string>) => Promise<any>, fetchReacts: (commentIds: Array<string>) => Promise<any>,
resetComments: (claimId: string) => void, resetComments: (claimId: string) => void,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
export default function CommentList(props: Props) { export default function CommentList(props: Props) {
@ -76,6 +79,8 @@ export default function CommentList(props: Props) {
fetchComment, fetchComment,
fetchReacts, fetchReacts,
resetComments, resetComments,
claimsByUri,
doFetchUserMemberships,
} = props; } = props;
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -100,6 +105,22 @@ export default function CommentList(props: Props) {
Boolean(othersReactsById) || !ENABLE_COMMENT_REACTIONS Boolean(othersReactsById) || !ENABLE_COMMENT_REACTIONS
); );
// get commenter claim ids for checking premium status
const commenterClaimIds = topLevelComments.map(function(comment) {
return comment.channel_id;
});
// update premium status
const shouldFetchUserMemberships = true;
useGetUserMemberships(
shouldFetchUserMemberships,
commenterClaimIds,
claimsByUri,
doFetchUserMemberships,
[topLevelComments],
true,
);
function changeSort(newSort) { function changeSort(newSort) {
if (sort !== newSort) { if (sort !== newSort) {
setSort(newSort); setSort(newSort);

View file

@ -1,35 +1,38 @@
// @flow // @flow
import 'scss/component/_comment-badge.scss'; import 'scss/component/_comment-badge.scss';
import classnames from 'classnames';
import Icon from 'component/common/icon'; import Icon from 'component/common/icon';
import React from 'react'; import React from 'react';
import Tooltip from 'component/common/tooltip'; import Tooltip from 'component/common/tooltip';
const LABEL_TYPES = {
ADMIN: 'Admin',
MOD: 'Moderator',
};
type Props = { type Props = {
icon: string, icon: string,
label: string, label: string,
size?: number, size?: number,
placement?: string,
hideTooltip?: boolean,
className?: string,
}; };
export default function CommentBadge(props: Props) { export default function CommentBadge(props: Props) {
const { icon, label, size = 20 } = props; const { icon, label, size = 20, placement = 'top', hideTooltip, className } = props;
return ( return (
<Tooltip title={label} placement="top"> <BadgeWrapper title={label} placement={placement} hideTooltip={hideTooltip} className={className}>
<span <span className="comment__badge">
className={classnames('comment__badge', {
'comment__badge--globalMod': label === LABEL_TYPES.ADMIN,
'comment__badge--mod': label === LABEL_TYPES.MOD,
})}
>
<Icon icon={icon} size={size} /> <Icon icon={icon} size={size} />
</span> </span>
</Tooltip> </BadgeWrapper>
); );
} }
type WrapperProps = {
hideTooltip?: boolean,
children: any,
};
const BadgeWrapper = (props: WrapperProps) => {
const { hideTooltip, children, ...tooltipProps } = props;
return !hideTooltip ? <Tooltip {...tooltipProps}>{children}</Tooltip> : children;
};

View file

@ -2537,21 +2537,58 @@ export const icons = {
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlSpace="preserve" xmlSpace="preserve"
> >
<style type="text/css">{'.st0{fill:FF3850}.st1{fill:#181021}.st2{fill:#FFFFFF}'}</style> <style type="text/css">
{'.st0--badge-mod{fill:#ff3850}.st1--badge-mod{fill:#181021}.st2--badge-mod{fill:#FFFFFF}'}
</style>
<g> <g>
<g> <g>
<path <path
className="st0" className="st0--badge-mod"
d="M11.69,6.77c4.86,0,7.55,0.9,8.52,1.31c1.29-1.46,3.28-4.14,3.28-6.76c0,0-4.17,4.86-6.92,5.12 c-1.25-0.87-2.77-1.38-4.41-1.38c0,0-3.21-0.06-4.63,1.31C4.81,6.44,0.51,1.32,0.51,1.32c0,2.61,1.97,5.27,3.25,6.74 C4.71,7.59,7.03,6.77,11.69,6.77z M19.87,19.38c0.02-0.13,0.04-0.27,0.04-0.4V12.8c0-1.03-0.21-2.02-0.58-2.92 c-0.83-0.33-3.25-1.11-7.64-1.11c-4.29,0-6.33,0.75-7,1.06c-0.38,0.91-0.6,1.91-0.6,2.97v6.18c0,0.13,0.02,0.26,0.04,0.39 C1.6,19.73,0,22.54,0,22.54L12,24l12-1.46C24,22.54,22.36,19.79,19.87,19.38z" d="M11.69,6.77c4.86,0,7.55,0.9,8.52,1.31c1.29-1.46,3.28-4.14,3.28-6.76c0,0-4.17,4.86-6.92,5.12 c-1.25-0.87-2.77-1.38-4.41-1.38c0,0-3.21-0.06-4.63,1.31C4.81,6.44,0.51,1.32,0.51,1.32c0,2.61,1.97,5.27,3.25,6.74 C4.71,7.59,7.03,6.77,11.69,6.77z M19.87,19.38c0.02-0.13,0.04-0.27,0.04-0.4V12.8c0-1.03-0.21-2.02-0.58-2.92 c-0.83-0.33-3.25-1.11-7.64-1.11c-4.29,0-6.33,0.75-7,1.06c-0.38,0.91-0.6,1.91-0.6,2.97v6.18c0,0.13,0.02,0.26,0.04,0.39 C1.6,19.73,0,22.54,0,22.54L12,24l12-1.46C24,22.54,22.36,19.79,19.87,19.38z"
/> />
</g> </g>
</g> </g>
<path <path
className="st1" className="st1--badge-mod"
d="M13,18.57H11c-2.27,0-4.12-0.82-4.12-2.88v-2.46c0-2.77,2.17-3.94,5.11-3.94s5.11,1.17,5.11,3.94v2.46 C17.11,17.75,15.27,18.57,13,18.57z" d="M13,18.57H11c-2.27,0-4.12-0.82-4.12-2.88v-2.46c0-2.77,2.17-3.94,5.11-3.94s5.11,1.17,5.11,3.94v2.46 C17.11,17.75,15.27,18.57,13,18.57z"
/> />
<path <path
className="st2" className="st2--badge-mod"
d="M15.06,15.25c-0.28,0-0.5-0.22-0.5-0.5v-1.42c0-0.32,0-1.31-1.63-1.31c-0.28,0-0.5-0.22-0.5-0.5 s0.22-0.5,0.5-0.5c1.65,0,2.63,0.86,2.63,2.31v1.42C15.56,15.02,15.33,15.25,15.06,15.25z"
/>
</svg>
),
[ICONS.BADGE_ADMIN]: (props: IconProps) => (
<svg
{...props}
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
width="24"
height="24"
viewBox="0 0 24 24"
xmlSpace="preserve"
>
<style type="text/css">
{'.st0--badge-admin{fill:#fe7500}.st1--badge-admin{fill:#181021}.st2--badge-admin{fill:#FFFFFF}'}
</style>
<g>
<g>
<path
className="st0--badge-admin"
d="M11.69,6.77c4.86,0,7.55,0.9,8.52,1.31c1.29-1.46,3.28-4.14,3.28-6.76c0,0-4.17,4.86-6.92,5.12 c-1.25-0.87-2.77-1.38-4.41-1.38c0,0-3.21-0.06-4.63,1.31C4.81,6.44,0.51,1.32,0.51,1.32c0,2.61,1.97,5.27,3.25,6.74 C4.71,7.59,7.03,6.77,11.69,6.77z M19.87,19.38c0.02-0.13,0.04-0.27,0.04-0.4V12.8c0-1.03-0.21-2.02-0.58-2.92 c-0.83-0.33-3.25-1.11-7.64-1.11c-4.29,0-6.33,0.75-7,1.06c-0.38,0.91-0.6,1.91-0.6,2.97v6.18c0,0.13,0.02,0.26,0.04,0.39 C1.6,19.73,0,22.54,0,22.54L12,24l12-1.46C24,22.54,22.36,19.79,19.87,19.38z"
/>
</g>
</g>
<path
className="st1--badge-admin"
d="M13,18.57H11c-2.27,0-4.12-0.82-4.12-2.88v-2.46c0-2.77,2.17-3.94,5.11-3.94s5.11,1.17,5.11,3.94v2.46 C17.11,17.75,15.27,18.57,13,18.57z"
/>
<path
className="st2--badge-admin"
d="M15.06,15.25c-0.28,0-0.5-0.22-0.5-0.5v-1.42c0-0.32,0-1.31-1.63-1.31c-0.28,0-0.5-0.22-0.5-0.5 s0.22-0.5,0.5-0.5c1.65,0,2.63,0.86,2.63,2.31v1.42C15.56,15.02,15.33,15.25,15.06,15.25z" d="M15.06,15.25c-0.28,0-0.5-0.22-0.5-0.5v-1.42c0-0.32,0-1.31-1.63-1.31c-0.28,0-0.5-0.22-0.5-0.5 s0.22-0.5,0.5-0.5c1.65,0,2.63,0.86,2.63,2.31v1.42C15.56,15.02,15.33,15.25,15.06,15.25z"
/> />
</svg> </svg>
@ -2570,26 +2607,30 @@ export const icons = {
viewBox="-1182 401 24 24" viewBox="-1182 401 24 24"
xmlSpace="preserve" xmlSpace="preserve"
> >
<style type="text/css">{'.st0{fill:#FF5490}.st1{fill:#81BBB9}.st2{fill:#2E2A2F}.st3{fill:#FFFFFF}'}</style> <style type="text/css">
{
'.st0--badge-streamer{fill:#FF5490}.st1--badge-streamer{fill:#81BBB9}.st2--badge-streamer{fill:#2E2A2F}.st3--badge-streamer{fill:#FFFFFF}'
}
</style>
<path <path
className="st0" className="st0--badge-streamer"
d="M-1169.8,406.4c-4.3,0-7.8,3.5-7.8,7.8c0,0.4,0,0.8,0.1,1.1h1c-0.1-0.4-0.1-0.7-0.1-1.1c0-3.7,3-6.8,6.8-6.8 s6.8,3,6.8,6.8c0,0.4,0,0.8-0.1,1.1h1c0.1-0.4,0.1-0.7,0.1-1.1C-1162.1,409.9-1165.5,406.4-1169.8,406.4z" d="M-1169.8,406.4c-4.3,0-7.8,3.5-7.8,7.8c0,0.4,0,0.8,0.1,1.1h1c-0.1-0.4-0.1-0.7-0.1-1.1c0-3.7,3-6.8,6.8-6.8 s6.8,3,6.8,6.8c0,0.4,0,0.8-0.1,1.1h1c0.1-0.4,0.1-0.7,0.1-1.1C-1162.1,409.9-1165.5,406.4-1169.8,406.4z"
/> />
<path <path
className="st0" className="st0--badge-streamer"
d="M-1180,414.2c0-5.6,4.6-10.2,10.2-10.2c5.6,0,10.2,4.6,10.2,10.2c0,2.2-0.7,4.3-1.9,5.9l0.8,0.6 c1.3-1.8,2.1-4.1,2.1-6.5c0-6.2-5-11.2-11.2-11.2c-6.2,0-11.2,5-11.2,11.2c0,2.1,0.6,4.1,1.6,5.8l1-0.3 C-1179.4,418-1180,416.2-1180,414.2z" d="M-1180,414.2c0-5.6,4.6-10.2,10.2-10.2c5.6,0,10.2,4.6,10.2,10.2c0,2.2-0.7,4.3-1.9,5.9l0.8,0.6 c1.3-1.8,2.1-4.1,2.1-6.5c0-6.2-5-11.2-11.2-11.2c-6.2,0-11.2,5-11.2,11.2c0,2.1,0.6,4.1,1.6,5.8l1-0.3 C-1179.4,418-1180,416.2-1180,414.2z"
/> />
<path className="st1" d="M-1163.7,419.4" /> <path className="st1--badge-streamer" d="M-1163.7,419.4" />
<path <path
className="st1" className="st1--badge-streamer"
d="M-1165.6,418.5c0-0.1,0-3.6,0-3.6c0-1.9-1-4.3-4.4-4.3s-4.4,2.4-4.4,4.3c0,0,0,3.6,0,3.6 c-1.4,0.2-1.8,0.7-1.8,0.7s2.2,2.7,6.2,2.7s6.2-2.7,6.2-2.7S-1164.2,418.7-1165.6,418.5z" d="M-1165.6,418.5c0-0.1,0-3.6,0-3.6c0-1.9-1-4.3-4.4-4.3s-4.4,2.4-4.4,4.3c0,0,0,3.6,0,3.6 c-1.4,0.2-1.8,0.7-1.8,0.7s2.2,2.7,6.2,2.7s6.2-2.7,6.2-2.7S-1164.2,418.7-1165.6,418.5z"
/> />
<path <path
className="st2" className="st2--badge-streamer"
d="M-1169.2,418.5h-1.5c-1.7,0-3.1-0.6-3.1-2.2v-1.9c0-2.1,1.6-3,3.9-3s3.9,0.9,3.9,3v1.9 C-1166.1,417.8-1167.5,418.5-1169.2,418.5z" d="M-1169.2,418.5h-1.5c-1.7,0-3.1-0.6-3.1-2.2v-1.9c0-2.1,1.6-3,3.9-3s3.9,0.9,3.9,3v1.9 C-1166.1,417.8-1167.5,418.5-1169.2,418.5z"
/> />
<path <path
className="st3" className="st3--badge-streamer"
d="M-1167.8,416.2c-0.2,0-0.4-0.2-0.4-0.4v-1.1c0-0.2,0-1-1.2-1c-0.2,0-0.4-0.2-0.4-0.4s0.2-0.4,0.4-0.4 c1.2,0,2,0.6,2,1.7v1.1C-1167.4,416.1-1167.6,416.2-1167.8,416.2z" d="M-1167.8,416.2c-0.2,0-0.4-0.2-0.4-0.4v-1.1c0-0.2,0-1-1.2-1c-0.2,0-0.4-0.2-0.4-0.4s0.2-0.4,0.4-0.4 c1.2,0,2,0.6,2,1.7v1.1C-1167.4,416.1-1167.6,416.2-1167.8,416.2z"
/> />
</svg> </svg>
@ -2717,6 +2758,368 @@ export const icons = {
<path d="M23.5,12.11a7,7,0,0,1-3.27,5.59.26.26,0,0,1-.32,0,.27.27,0,0,1-.05-.31A2.71,2.71,0,0,0,20,17c.65-1.4.5-2.85-.34-3.25s-2,.41-2.67,1.77c.06-.93-.26-1.7-.86-1.88-1.27-.4-1.77,1.24-4.17,5.44-2.44-4.27-2.9-5.84-4.17-5.44-.6.18-.92.95-.86,1.88-.66-1.36-1.84-2.15-2.67-1.77S3.31,15.63,4,17a2.71,2.71,0,0,0,.18.34.27.27,0,0,1,0,.31.26.26,0,0,1-.32,0A7,7,0,0,1,.5,12.11C.5,8.93,3.17,6.18,7,4.9a.25.25,0,0,1,.32.3L7,6.73a3.37,3.37,0,0,0,.78,3,1,1,0,0,0,1.1.28,1,1,0,0,0,.65-.94V5.61a.25.25,0,0,1,.4-.2l1.6,1.2h1l1.6-1.2a.25.25,0,0,1,.4.2V9.05a1,1,0,0,0,.65.94,1,1,0,0,0,1.1-.28,3.35,3.35,0,0,0,.78-3L16.65,5.2A.25.25,0,0,1,17,4.9C20.83,6.18,23.5,8.93,23.5,12.11Z" /> <path d="M23.5,12.11a7,7,0,0,1-3.27,5.59.26.26,0,0,1-.32,0,.27.27,0,0,1-.05-.31A2.71,2.71,0,0,0,20,17c.65-1.4.5-2.85-.34-3.25s-2,.41-2.67,1.77c.06-.93-.26-1.7-.86-1.88-1.27-.4-1.77,1.24-4.17,5.44-2.44-4.27-2.9-5.84-4.17-5.44-.6.18-.92.95-.86,1.88-.66-1.36-1.84-2.15-2.67-1.77S3.31,15.63,4,17a2.71,2.71,0,0,0,.18.34.27.27,0,0,1,0,.31.26.26,0,0,1-.32,0A7,7,0,0,1,.5,12.11C.5,8.93,3.17,6.18,7,4.9a.25.25,0,0,1,.32.3L7,6.73a3.37,3.37,0,0,0,.78,3,1,1,0,0,0,1.1.28,1,1,0,0,0,.65-.94V5.61a.25.25,0,0,1,.4-.2l1.6,1.2h1l1.6-1.2a.25.25,0,0,1,.4.2V9.05a1,1,0,0,0,.65.94,1,1,0,0,0,1.1-.28,3.35,3.35,0,0,0,.78-3L16.65,5.2A.25.25,0,0,1,17,4.9C20.83,6.18,23.5,8.93,23.5,12.11Z" />
</g> </g>
), ),
[ICONS.EARLY_ACCESS]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={'40'}
height={'40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{
'.early-access--st0{fill:none;stroke:#DCBDA2;stroke-width:1.4173;stroke-miterlimit:10;}.early-access--st1{fill:#DCBEA2;}'
}
</style>
<circle className="early-access--st0" cx="304.9" cy="346.6" r="13.7" />
<g>
<ellipse className="early-access--st0" cx="301.2" cy="346.5" rx="3.5" ry="3.8" />
<line className="early-access--st0" x1="304.7" y1="346.5" x2="312.5" y2="346.5" />
<line className="early-access--st0" x1="310.3" y1="346.6" x2="310.3" y2="349.3" />
</g>
<circle className="early-access--st0" cx="304.9" cy="390.6" r="13.7" />
<path
className="early-access--st0"
d="M316.2,296.7v6.4c0,0.9-0.5,1.8-1.3,2.3l-9,5.2c-0.8,0.5-1.8,0.5-2.6,0l-9-5.2c-0.8-0.5-1.3-1.3-1.3-2.3v-10.4c0-0.9,0.5-1.8,1.3-2.3l9-5.2c0.8-0.5,1.8-0.5,2.6,0l9,5.2"
/>
<polyline
className="early-access--st0"
points="318.7,290.8 304.4,306.8 301.3,301.3 295.8,298.2 301.3,295 304.4,289.5 307.5,295 "
/>
<polyline className="early-access--st0" points="299,310.2 299,316.4 304.8,313.1 309.9,316.4 309.9,310.2 " />
<line className="early-access--st0" x1="314.7" y1="380.8" x2="295.1" y2="400.5" />
<text
transform="matrix(1 0 0 1 294.7307 394.0567)"
style={{ fill: '#DCBDA2', 'fontFamily': 'Roboto-Bold', 'fontSize': '10.1968px' }}
>
ADS
</text>
<g id="XMLID_53_">
<g id="XMLID_493_">
<path
id="XMLID_494_"
className="early-access--st1"
d="M16,1.6C8,1.6,1.6,8,1.6,16S8,30.4,16,30.4S30.4,24,30.4,16S24,1.6,16,1.6z M16,28.9C8.9,28.9,3.1,23.1,3.1,16S8.9,3.1,16,3.1S28.9,8.9,28.9,16S23.1,28.9,16,28.9z M12.3,11.4c-2.3,0-4.2,2-4.2,4.5s1.9,4.5,4.2,4.5c2.1,0,3.8-1.6,4.1-3.8h4.2v2h1.5v-2h1.4v-1.5h-7.2C16.1,13,14.4,11.4,12.3,11.4z M12.3,18.9c-1.5,0-2.7-1.3-2.7-3s1.2-3,2.7-3c1.5,0,2.7,1.3,2.7,3S13.8,18.9,12.3,18.9z"
/>
</g>
</g>
</svg>
),
[ICONS.MEMBER_BADGE]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={'40'}
height={'40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{
'.member-bage--st0{fill:none;stroke:#DCBDA2;stroke-width:1.4173;stroke-miterlimit:10;}.member-bage--st1{fill:#DCBEA2;}'
}
</style>
<circle className="member-bage--st0" cx="304.9" cy="399.4" r="13.7" />
<g>
<ellipse className="member-bage--st0" cx="301.2" cy="399.3" rx="3.5" ry="3.8" />
<line className="member-bage--st0" x1="304.7" y1="399.3" x2="312.5" y2="399.3" />
<line className="member-bage--st0" x1="310.3" y1="399.3" x2="310.3" y2="402" />
</g>
<circle className="member-bage--st0" cx="304.9" cy="443.4" r="13.7" />
<path
className="member-bage--st0"
d="M316.2,349.5v6.4c0,0.9-0.5,1.8-1.3,2.3l-9,5.2c-0.8,0.5-1.8,0.5-2.6,0l-9-5.2c-0.8-0.5-1.3-1.3-1.3-2.3v-10.4
c0-0.9,0.5-1.8,1.3-2.3l9-5.2c0.8-0.5,1.8-0.5,2.6,0l9,5.2"
/>
<polyline
className="member-bage--st0"
points="318.7,343.5 304.4,359.6 301.3,354 295.8,350.9 301.3,347.8 304.4,342.2 307.5,347.8 "
/>
<polyline className="member-bage--st0" points="299,363 299,369.1 304.8,365.9 309.9,369.1 309.9,363 " />
<line className="member-bage--st0" x1="314.7" y1="433.6" x2="295.1" y2="453.2" />
<text
transform="matrix(1 0 0 1 294.7307 446.8067)"
style={{ fill: '#DCBDA2', 'fontFamily': 'Roboto-Bold', 'fontSize': '10.1968px' }}
>
ADS
</text>
<g id="XMLID_187_">
<g id="XMLID_250_">
<path
id="XMLID_251_"
className="member-bage--st1"
d="M26.7,19c0,0.7-0.4,1.3-0.9,1.6l-9,5.2c-0.6,0.3-1.3,0.3-1.9,0l-9-5.2C5.3,20.3,5,19.6,5,19
V8.6c0-0.7,0.4-1.3,0.9-1.6l9-5.2c0.6-0.3,1.3-0.3,1.9,0l9,5.2l0.8-1.3l-9-5.2c-1-0.6-2.3-0.6-3.4,0l-9,5.2
c-1,0.6-1.7,1.7-1.7,2.9V19c0,1.2,0.6,2.3,1.7,2.9l9,5.2c0.5,0.3,1.1,0.5,1.7,0.5s1.2-0.2,1.7-0.5l9-5.2c1-0.6,1.7-1.7,1.7-2.9
v-6.4h-1.5V19z M13.1,16.5L8.5,14l4.6-2.6l2.6-4.6l2.5,4.4l1.3-0.7l-3.8-6.7L12,10.3L5.5,14l6.5,3.7l3.5,6.3l15-16.8l-1.1-1
L15.8,21.3L13.1,16.5z M20.4,29.7L16,27.8l-4.7,2v-4.1H9.8v6.3l6.2-2.5l5.9,2.6v-6.3h-1.5V29.7z"
/>
</g>
</g>
</svg>
),
[ICONS.NO_ADS]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={'40'}
height={'40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{'.st0--no-ads{fill:none;stroke:#DCBDA2;stroke-width:1.4173;stroke-miterlimit:10;}.st1--no-ads{fill:#DCBEA2;}'}
</style>
<circle className="st0--no-ads" cx="304.9" cy="297.6" r="13.7" />
<g>
<ellipse className="st0--no-ads" cx="301.2" cy="297.5" rx="3.5" ry="3.8" />
<line className="st0--no-ads" x1="304.7" y1="297.5" x2="312.5" y2="297.5" />
<line className="st0--no-ads" x1="310.3" y1="297.6" x2="310.3" y2="300.3" />
</g>
<circle className="st0--no-ads" cx="304.9" cy="341.6" r="13.7" />
<path
className="st0--no-ads"
d="M316.2,247.7v6.4c0,0.9-0.5,1.8-1.3,2.3l-9,5.2c-0.8,0.5-1.8,0.5-2.6,0l-9-5.2c-0.8-0.5-1.3-1.3-1.3-2.3v-10.4
c0-0.9,0.5-1.8,1.3-2.3l9-5.2c0.8-0.5,1.8-0.5,2.6,0l9,5.2"
/>
<polyline
className="st0--no-ads"
points="318.7,241.8 304.4,257.8 301.3,252.3 295.8,249.2 301.3,246 304.4,240.5 307.5,246 "
/>
<polyline className="st0--no-ads" points="299,261.2 299,267.4 304.8,264.1 309.9,267.4 309.9,261.2 " />
<line className="st0--no-ads" x1="314.7" y1="331.8" x2="295.1" y2="351.5" />
<text
transform="matrix(1 0 0 1 294.7307 345.0567)"
style={{ fill: '#DCBDA2', 'fontFamily': 'Roboto-Bold', 'fontSize': '10.1968px' }}
>
ADS
</text>
<g id="XMLID_109_">
<path
id="XMLID_190_"
className="st1--no-ads"
d="M16,1.6C8,1.6,1.6,8,1.6,16S8,30.4,16,30.4S30.4,24,30.4,16S24,1.6,16,1.6z M16,3.1
c3.3,0,6.3,1.3,8.6,3.3L18,13c-0.2-0.1-0.3-0.3-0.5-0.4c-0.5-0.3-1.1-0.4-1.7-0.4h-2.2v5.3l-1.2,1.2l-2.4-6.4H8.5l-2.7,7.2h1.6
l0.5-1.5h2.6l0.5,1.5h0.5l-5.2,5.2c-2-2.3-3.3-5.3-3.3-8.6C3.1,8.9,8.9,3.1,16,3.1z M17.6,15.6V16c0,0.7-0.2,1.3-0.5,1.6
s-0.8,0.6-1.3,0.6H15V18l2.5-2.5C17.6,15.5,17.6,15.6,17.6,15.6z M15,15.9v-2.5h0.7c0.6,0,1,0.2,1.3,0.5L15,15.9z M10.2,16.7H8.3
L9.2,14L10.2,16.7z M16,28.9c-3.3,0-6.3-1.3-8.6-3.3l6.2-6.2h2.1c0.6,0,1.2-0.1,1.7-0.4c0.5-0.3,0.9-0.7,1.2-1.2
c0.3-0.5,0.4-1.1,0.4-1.8v-0.3c0-0.5-0.1-0.9-0.3-1.4l6.8-6.8c2,2.3,3.3,5.3,3.3,8.6C28.9,23.1,23.1,28.9,16,28.9z"
/>
<path
id="XMLID_195_"
className="st1--no-ads"
d="M23.2,15.1c-0.5-0.1-0.8-0.3-1-0.4c-0.2-0.2-0.4-0.4-0.4-0.6c0-0.3,0.1-0.5,0.3-0.6
c0.2-0.2,0.5-0.2,0.9-0.2c0.4,0,0.7,0.1,0.9,0.3c0.2,0.2,0.3,0.4,0.3,0.8h1.5c0-0.4-0.1-0.8-0.3-1.2s-0.5-0.6-0.9-0.8
c-0.4-0.2-0.9-0.3-1.4-0.3c-0.5,0-1,0.1-1.4,0.3s-0.7,0.4-1,0.7c-0.2,0.3-0.3,0.7-0.3,1c0,0.8,0.4,1.4,1.2,1.8
c0.3,0.2,0.7,0.3,1.2,0.5c0.5,0.2,0.9,0.3,1.1,0.5c0.2,0.2,0.3,0.4,0.3,0.6c0,0.3-0.1,0.5-0.3,0.6c-0.2,0.1-0.5,0.2-0.8,0.2
c-1,0-1.4-0.4-1.4-1.2h-1.5c0,0.5,0.1,0.9,0.4,1.2c0.2,0.4,0.6,0.6,1,0.8s1,0.3,1.5,0.3c0.8,0,1.4-0.2,1.9-0.5s0.7-0.8,0.7-1.5
c0-0.6-0.2-1-0.6-1.4C24.6,15.7,24,15.4,23.2,15.1z"
/>
</g>
</svg>
),
[ICONS.PREMIUM]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={props.size || '40'}
height={props.size || '40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{'.premium--st0{fill:#898DB3;}'}
{'.premium--st1{fill:#D8D2E8;}'}
{'.premium--st2{fill:#CAC2DF;}'}
{'.premium--st3{opacity:0.27;fill:#74749A;}'}
{'.premium--st4{fill:none;stroke:#CAC2DF;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;}'}
{'.premium--st5{fill:#626092;}.premium--st6{opacity:0.2;fill:#FFFFFF;}'}
</style>
<path
id="XMLID_122_"
className="premium--st0"
d="M0,12.7v0.8c0,2.3,2,4.2,4.4,4.2h23.2c2.4,0,4.4-1.9,4.4-4.2v-0.8H0z"
/>
<path
id="XMLID_20_"
className="premium--st1"
d="M1.8,14.4c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9
c0.2-0.2,0.4-0.5,0.5-0.8H1.8z"
/>
<path
id="XMLID_27_"
className="premium--st2"
d="M2.3,15.2c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9H2.3z"
/>
<rect id="XMLID_28_" x="5.2" y="12.7" className="premium--st3" width="21.7" height="5.1" />
<path
id="XMLID_125_"
className="premium--st0"
d="M1.4,16.1v0.8c0,2.3,2,4.2,4.4,4.2h20.4c2.4,0,4.4-1.9,4.4-4.2v-0.8H1.4z"
/>
<path
id="XMLID_237_"
className="premium--st1"
d="M3.2,17.8c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9
c0.2-0.2,0.4-0.5,0.5-0.8H3.2z"
/>
<path
id="XMLID_120_"
className="premium--st2"
d="M3.7,18.6c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9H3.7z"
/>
<rect id="XMLID_29_" x="5.2" y="16.1" className="premium--st3" width="21.7" height="5.1" />
<path id="XMLID_75_" className="premium--st4" d="M6.4,14.3" />
<path id="XMLID_25_" className="premium--st4" d="M2.7,14.3" />
<path
id="XMLID_124_"
className="premium--st0"
d="M25.1,10.4l-7.9-4.6c-0.7-0.4-1.6-0.4-2.3,0l-7.9,4.6c-0.7,0.4-1.2,1.2-1.2,2v9.1
c0,0.8,0.4,1.6,1.2,2l7.9,4.6c0.4,0.2,0.8,0.3,1.2,0.3c0.4,0,0.8-0.1,1.2-0.3l7.9-4.6c0.7-0.4,1.2-1.2,1.2-2v-9.1
C26.2,11.6,25.8,10.8,25.1,10.4z"
/>
<path
id="XMLID_123_"
className="premium--st2"
d="M16.3,7.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1l-7.9,4.6c-0.2,0.1-0.3,0.3-0.3,0.5
v9.1c0,0.2,0.1,0.4,0.3,0.5l7.9,4.6c0.2,0.1,0.4,0.1,0.6,0l7.9-4.6c0.2-0.1,0.3-0.3,0.3-0.5v-9.1c0-0.2-0.1-0.4-0.3-0.5L16.3,7.3z"
/>
<polygon
id="XMLID_19_"
className="premium--st5"
points="20.5,20.1 11.6,20.1 11.2,14.3 14.2,15.8 16,12.4 17.9,15.8 21,14.2 "
/>
<polygon
id="XMLID_18_"
className="premium--st6"
points="16.1,20.8 21.1,20.8 21.7,13.2 18.1,14.9 16.1,11.3 16.1,7.8 23.9,12.3 23.9,21.6 16.1,25.9 "
/>
<polygon id="XMLID_17_" className="premium--st6" points="16.1,13.4 16.1,19.6 20.1,19.6 20.5,15.2 17.8,16.8 " />
</svg>
),
[ICONS.PREMIUM_PLUS]: (props: CustomProps) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 35 30"
width={props.size || '40'}
height={props.size || '40'}
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<style type="text/css">
{'.premium-plus--st0{fill:#C36017;}'}
{'.premium-plus--st1{fill:#FAC65D;}'}
{'.premium-plus--st2{fill:#F9B915;}'}
{'.premium-plus--st3{opacity:0.3;fill:#955000;}'}
{'.premium-plus--st4{fill:none;stroke:#CAC2DF;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;}'}
{'.premium-plus--st5{fill:#C95B16;}'}
{'.premium-plus--st6{opacity:0.2;fill:#FFFFFF;}'}
</style>
<path
id="XMLID_141_"
className="premium-plus--st0"
d="M0,11.7v0.8c0,2.3,2,4.2,4.4,4.2h23.2c2.4,0,4.4-1.9,4.4-4.2v-0.8H0z"
/>
<path
id="XMLID_133_"
className="premium-plus--st1"
d="M1.8,13.4c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9c0.2-0.2,0.4-0.5,0.5-0.8H1.8z"
/>
<path
id="XMLID_139_"
className="premium-plus--st2"
d="M2.3,14.2c0.5,0.6,1.2,0.9,2.1,0.9h23.2c0.8,0,1.6-0.4,2.1-0.9H2.3z"
/>
<path
id="XMLID_136_"
className="premium-plus--st0"
d="M1.4,15.1v0.8c0,2.3,2,4.2,4.4,4.2h20.4c2.4,0,4.4-1.9,4.4-4.2v-0.8H1.4z"
/>
<path
id="XMLID_131_"
className="premium-plus--st1"
d="M3.2,16.8c0.1,0.3,0.3,0.6,0.5,0.8c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9c0.2-0.2,0.4-0.5,0.5-0.8H3.2z"
/>
<path
id="XMLID_134_"
className="premium-plus--st2"
d="M3.7,17.6c0.5,0.6,1.2,0.9,2.1,0.9h20.4c0.8,0,1.6-0.4,2.1-0.9H3.7z"
/>
<path
id="XMLID_260_"
className="premium-plus--st0"
d="M2.6,18.6v0.8c0,2.3,2,4.2,4.4,4.2h18.2c2.4,0,4.1-1.9,4.1-4.2v-0.8H2.6z"
/>
<path
id="XMLID_137_"
className="premium-plus--st3"
d="M7.4,23.6h17.4c0.7,0,1.9-1.6,1.9-2.3l0.1-9.6H5.3v9.7C5.3,22.1,6.7,23.6,7.4,23.6z"
/>
<path
id="XMLID_257_"
className="premium-plus--st1"
d="M4.5,20.3c0.1,0.3,0.3,0.6,0.5,0.8C5.5,21.6,6.2,22,7,22h18.2c0.8,0,1.2-0.4,1.7-0.9c0.2-0.2,0.4-0.5,0.5-0.8H4.5z"
/>
<path
id="XMLID_254_"
className="premium-plus--st2"
d="M5,21.1C5.5,21.6,6.2,22,7,22h18.2c0.8,0,1.2-0.4,1.7-0.9H5z"
/>
<path id="XMLID_130_" className="premium-plus--st4" d="M6.4,14.3" />
<path id="XMLID_129_" className="premium-plus--st4" d="M2.7,14.3" />
<path
id="XMLID_128_"
className="premium-plus--st0"
d="M25.1,10.4l-7.9-4.6c-0.7-0.4-1.6-0.4-2.3,0l-7.9,4.6c-0.7,0.4-1.2,1.2-1.2,2v9.1c0,0.8,0.4,1.6,1.2,2l7.9,4.6c0.4,0.2,0.8,0.3,1.2,0.3c0.4,0,0.8-0.1,1.2-0.3l7.9-4.6c0.7-0.4,1.2-1.2,1.2-2v-9.1C26.2,11.6,25.8,10.8,25.1,10.4z"
/>
<path
id="XMLID_127_"
className="premium-plus--st2"
d="M16.3,7.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1l-7.9,4.6c-0.2,0.1-0.3,0.3-0.3,0.5v9.1c0,0.2,0.1,0.4,0.3,0.5l7.9,4.6c0.2,0.1,0.4,0.1,0.6,0l7.9-4.6c0.2-0.1,0.3-0.3,0.3-0.5v-9.1c0-0.2-0.1-0.4-0.3-0.5L16.3,7.3z"
/>
<polygon
id="XMLID_126_"
className="premium-plus--st5"
points="20.5,20.1 11.6,20.1 11.2,14.3 14.2,15.8 16,12.4 17.9,15.8 21,14.2 "
/>
<polygon
id="XMLID_24_"
className="premium-plus--st6"
points="16.1,20.8 21.1,20.8 21.7,13.2 18.1,14.9 16.1,11.3 16.1,7.8 23.9,12.3 23.9,21.616.1,25.9 "
/>
<polygon
id="XMLID_23_"
className="premium-plus--st6"
points="16.1,13.4 16.1,19.6 20.1,19.6 20.5,15.2 17.8,16.8 "
/>
</svg>
),
[ICONS.UPGRADE]: buildIcon(
<g>
<path d="m2 6 10-5 10 5M2 6v12l10 5 10-5V6" />
<circle cx={12} cy={10} r={5.25} />
<path d="M8.5 14.5 6 17h3l1.5 2.5 1-4h1l1 4L15 17h3l-2-2.5" />
</g>
),
[ICONS.FEATURED]: (props: IconProps) => { [ICONS.FEATURED]: (props: IconProps) => {
const { size = 24, color = 'currentColor', ...otherProps } = props; const { size = 24, color = 'currentColor', ...otherProps } = props;

View file

@ -58,6 +58,7 @@ type MarkdownProps = {
disableTimestamps?: boolean, disableTimestamps?: boolean,
stakedLevel?: number, stakedLevel?: number,
setUserMention?: (boolean) => void, setUserMention?: (boolean) => void,
hasMembership?: string,
}; };
// **************************************************************************** // ****************************************************************************
@ -156,6 +157,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
disableTimestamps, disableTimestamps,
stakedLevel, stakedLevel,
setUserMention, setUserMention,
hasMembership,
} = props; } = props;
const strippedContent = content const strippedContent = content
@ -189,7 +191,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
parentCommentId={parentCommentId} parentCommentId={parentCommentId}
isMarkdownPost={isMarkdownPost} isMarkdownPost={isMarkdownPost}
simpleLinks={simpleLinks} simpleLinks={simpleLinks}
allowPreview={isStakeEnoughForPreview(stakedLevel)} allowPreview={isStakeEnoughForPreview(stakedLevel) || hasMembership}
setUserMention={setUserMention} setUserMention={setUserMention}
/> />
), ),
@ -198,7 +200,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
img: (imgProps) => { img: (imgProps) => {
const imageCdnUrl = const imageCdnUrl =
getThumbnailCdnUrl({ thumbnail: imgProps.src, width: 0, height: 0, quality: 85 }) || MISSING_THUMB_DEFAULT; getThumbnailCdnUrl({ thumbnail: imgProps.src, width: 0, height: 0, quality: 85 }) || MISSING_THUMB_DEFAULT;
if (isStakeEnoughForPreview(stakedLevel) && !isEmote(imgProps.title, imgProps.src)) { if ((isStakeEnoughForPreview(stakedLevel) || hasMembership) && !isEmote(imgProps.title, imgProps.src)) {
return <ZoomableImage {...imgProps} src={imageCdnUrl} />; return <ZoomableImage {...imgProps} src={imageCdnUrl} />;
} else { } else {
return ( return (
@ -206,7 +208,7 @@ export default React.memo<MarkdownProps>(function MarkdownPreview(props: Markdow
src={imageCdnUrl} src={imageCdnUrl}
alt={imgProps.alt} alt={imgProps.alt}
title={imgProps.title} title={imgProps.title}
helpText={__("This channel isn't staking enough Credits for inline image previews.")} helpText={__('Odysee Premium required to enable image previews')}
/> />
); );
} }

View file

@ -0,0 +1,52 @@
// @flow
import 'scss/component/_comment-badge.scss';
import * as ICONS from 'constants/icons';
import React from 'react';
import CommentBadge from './comment-badge';
import Button from 'component/button';
type Props = {
membership: ?string,
linkPage?: boolean,
placement?: string,
className?: string,
hideTooltip?: boolean,
};
export default function PremiumBadge(props: Props) {
const { membership, linkPage, placement, className, hideTooltip } = props;
const badgeToShow = membership === 'Premium' ? 'silver' : membership === 'Premium+' ? 'gold' : null;
if (!badgeToShow) return null;
const badgeProps = { size: 40, placement, hideTooltip, className };
return (
<BadgeWrapper linkPage={linkPage}>
{badgeToShow === 'silver' ? (
<CommentBadge label={__('Premium')} icon={ICONS.PREMIUM} {...badgeProps} />
) : (
badgeToShow === 'gold' && <CommentBadge label={__('Premium +')} icon={ICONS.PREMIUM_PLUS} {...badgeProps} />
)}
</BadgeWrapper>
);
}
type WrapperProps = {
linkPage?: boolean,
children: any,
};
const BadgeWrapper = (props: WrapperProps) => {
const { linkPage, children } = props;
return linkPage ? (
<Button navigate="/$/membership">
{children}
</Button>
) : (
children
);
};

View file

@ -9,6 +9,7 @@ type Props = {
disableInteractive?: boolean, disableInteractive?: boolean,
enterDelay?: number, enterDelay?: number,
title?: string | Node, title?: string | Node,
className?: string,
followCursor?: boolean, followCursor?: boolean,
placement?: string, // https://mui.com/api/tooltip/ placement?: string, // https://mui.com/api/tooltip/
}; };
@ -20,6 +21,7 @@ function Tooltip(props: Props) {
disableInteractive = true, disableInteractive = true,
enterDelay = 300, enterDelay = 300,
title, title,
className,
followCursor = false, followCursor = false,
placement = 'bottom', placement = 'bottom',
} = props; } = props;
@ -33,6 +35,7 @@ function Tooltip(props: Props) {
title={title} title={title}
followCursor={followCursor} followCursor={followCursor}
placement={placement} placement={placement}
classes={{ tooltip: className }}
> >
{children} {children}
</MUITooltip> </MUITooltip>

View file

@ -1,17 +1,17 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doSetClientSetting } from 'redux/actions/settings'; import { doSetClientSetting } from 'redux/actions/settings';
import { selectActiveChannelStakedLevel } from 'redux/selectors/app';
import { selectClientSetting } from 'redux/selectors/settings'; import { selectClientSetting } from 'redux/selectors/settings';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import HeaderMenuButtons from './view'; import HeaderMenuButtons from './view';
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; import { selectUserVerifiedEmail, selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import { doOpenModal } from 'redux/actions/app';
const select = (state) => ({ const select = (state) => ({
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
authenticated: selectUserVerifiedEmail(state), authenticated: selectUserVerifiedEmail(state),
automaticDarkModeEnabled: selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED), automaticDarkModeEnabled: selectClientSetting(state, SETTINGS.AUTOMATIC_DARK_MODE_ENABLED),
currentTheme: selectClientSetting(state, SETTINGS.THEME), currentTheme: selectClientSetting(state, SETTINGS.THEME),
user: selectUser(state), user: selectUser(state),
odyseeMembership: selectOdyseeMembershipName(state),
}); });
const perform = (dispatch) => ({ const perform = (dispatch) => ({
@ -19,6 +19,7 @@ const perform = (dispatch) => ({
if (automaticDarkModeEnabled) dispatch(doSetClientSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, false)); if (automaticDarkModeEnabled) dispatch(doSetClientSetting(SETTINGS.AUTOMATIC_DARK_MODE_ENABLED, false));
dispatch(doSetClientSetting(SETTINGS.THEME, currentTheme === 'dark' ? 'light' : 'dark', true)); dispatch(doSetClientSetting(SETTINGS.THEME, currentTheme === 'dark' ? 'light' : 'dark', true));
}, },
doOpenModal: (id, params) => dispatch(doOpenModal(id, params)),
}); });
export default connect(select, perform)(HeaderMenuButtons); export default connect(select, perform)(HeaderMenuButtons);

View file

@ -1,7 +1,7 @@
// @flow // @flow
import 'scss/component/_header.scss'; import 'scss/component/_header.scss';
import { ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS, CHANNEL_STAKED_LEVEL_LIVESTREAM } from 'config'; import { ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button'; import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import * as ICONS from 'constants/icons'; import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
@ -18,25 +18,17 @@ type HeaderMenuButtonProps = {
currentTheme: string, currentTheme: string,
user: ?User, user: ?User,
handleThemeToggle: (boolean, string) => void, handleThemeToggle: (boolean, string) => void,
doOpenModal: (string, {}) => void,
odyseeMembership: string,
}; };
export default function HeaderMenuButtons(props: HeaderMenuButtonProps) { export default function HeaderMenuButtons(props: HeaderMenuButtonProps) {
const { const { authenticated, automaticDarkModeEnabled, currentTheme, user, handleThemeToggle, odyseeMembership } = props;
authenticated,
automaticDarkModeEnabled, const isOnMembershipPage = window.location.pathname === `/$/${PAGES.ODYSEE_MEMBERSHIP}`;
currentTheme,
activeChannelStakedLevel,
user,
handleThemeToggle,
} = props;
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui); const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
const livestreamEnabled = Boolean( const livestreamEnabled = Boolean(ENABLE_NO_SOURCE_CLAIMS && user && !user.odysee_live_disabled);
ENABLE_NO_SOURCE_CLAIMS &&
user &&
!user.odysee_live_disabled &&
(activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled)
);
return ( return (
<div className="header__buttons"> <div className="header__buttons">
@ -68,6 +60,10 @@ export default function HeaderMenuButtons(props: HeaderMenuButtonProps) {
<MenuList className="menu__list--header"> <MenuList className="menu__list--header">
<HeaderMenuLink page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} /> <HeaderMenuLink page={PAGES.SETTINGS} icon={ICONS.SETTINGS} name={__('Settings')} />
{/* don't show upgrade button if on membership page or already have a membership */}
{!isOnMembershipPage && !odyseeMembership && (
<HeaderMenuLink page={PAGES.ODYSEE_MEMBERSHIP} icon={ICONS.UPGRADE} name={__('Odysee Premium')} />
)}
<HeaderMenuLink page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} /> <HeaderMenuLink page={PAGES.HELP} icon={ICONS.HELP} name={__('Help')} />
<MenuItem className="menu__link" onSelect={() => handleThemeToggle(automaticDarkModeEnabled, currentTheme)}> <MenuItem className="menu__link" onSelect={() => handleThemeToggle(automaticDarkModeEnabled, currentTheme)}>

View file

@ -35,14 +35,13 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
) : ( ) : (
<MenuButton <MenuButton
aria-label={__('Your account')} aria-label={__('Your account')}
title={__('Your account')}
className={classnames('header__navigationItem', { className={classnames('header__navigationItem', {
'header__navigationItem--icon': !activeChannelUrl, 'header__navigationItem--icon': !activeChannelUrl,
'header__navigationItem--profilePic': activeChannelUrl, 'header__navigationItem--profilePic': activeChannelUrl,
})} })}
> >
{activeChannelUrl ? ( {activeChannelUrl ? (
<ChannelThumbnail uri={activeChannelUrl} hideTooltip small noLazyLoad /> <ChannelThumbnail uri={activeChannelUrl} hideTooltip small noLazyLoad showMemberBadge />
) : ( ) : (
<Icon size={18} icon={ICONS.ACCOUNT} aria-hidden /> <Icon size={18} icon={ICONS.ACCOUNT} aria-hidden />
)} )}
@ -57,6 +56,7 @@ export default function HeaderProfileMenuButton(props: HeaderMenuButtonProps) {
<HeaderMenuLink page={PAGES.CREATOR_DASHBOARD} icon={ICONS.ANALYTICS} name={__('Creator Analytics')} /> <HeaderMenuLink page={PAGES.CREATOR_DASHBOARD} icon={ICONS.ANALYTICS} name={__('Creator Analytics')} />
<HeaderMenuLink page={PAGES.REWARDS} icon={ICONS.REWARDS} name={__('Rewards')} /> <HeaderMenuLink page={PAGES.REWARDS} icon={ICONS.REWARDS} name={__('Rewards')} />
<HeaderMenuLink page={PAGES.INVITE} icon={ICONS.INVITE} name={__('Invites')} /> <HeaderMenuLink page={PAGES.INVITE} icon={ICONS.INVITE} name={__('Invites')} />
<HeaderMenuLink page={PAGES.ODYSEE_MEMBERSHIP} icon={ICONS.UPGRADE} name={__('Odysee Premium')} />
<MenuItem onSelect={signOut}> <MenuItem onSelect={signOut}>
<div className="menu__link"> <div className="menu__link">

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream'; import { MAX_LIVESTREAM_COMMENTS } from 'constants/livestream';
import { doResolveUris } from 'redux/actions/claims'; import { doResolveUris } from 'redux/actions/claims';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri, selectClaimsByUri } from 'redux/selectors/claims';
import { doCommentList, doSuperChatList } from 'redux/actions/comments'; import { doCommentList, doSuperChatList } from 'redux/actions/comments';
import { import {
selectTopLevelCommentsForUri, selectTopLevelCommentsForUri,
@ -9,6 +9,7 @@ import {
selectPinnedCommentsForUri, selectPinnedCommentsForUri,
} from 'redux/selectors/comments'; } from 'redux/selectors/comments';
import { selectThemePath } from 'redux/selectors/settings'; import { selectThemePath } from 'redux/selectors/settings';
import { doFetchUserMemberships } from 'redux/actions/user';
import LivestreamChatLayout from './view'; import LivestreamChatLayout from './view';
const select = (state, props) => { const select = (state, props) => {
@ -21,6 +22,7 @@ const select = (state, props) => {
pinnedComments: selectPinnedCommentsForUri(state, uri), pinnedComments: selectPinnedCommentsForUri(state, uri),
superChats: selectSuperChatsForUri(state, uri), superChats: selectSuperChatsForUri(state, uri),
theme: selectThemePath(state), theme: selectThemePath(state),
claimsByUri: selectClaimsByUri(state),
}; };
}; };
@ -28,6 +30,7 @@ const perform = {
doCommentList, doCommentList,
doSuperChatList, doSuperChatList,
doResolveUris, doResolveUris,
doFetchUserMemberships,
}; };
export default connect(select, perform)(LivestreamChatLayout); export default connect(select, perform)(LivestreamChatLayout);

View file

@ -18,6 +18,7 @@ import React from 'react';
import Yrbl from 'component/yrbl'; import Yrbl from 'component/yrbl';
import { getTipValues } from 'util/livestream'; import { getTipValues } from 'util/livestream';
import Slide from '@mui/material/Slide'; import Slide from '@mui/material/Slide';
import useGetUserMemberships from 'effects/use-get-user-memberships';
export const VIEW_MODES = { export const VIEW_MODES = {
CHAT: 'chat', CHAT: 'chat',
@ -49,6 +50,8 @@ type Props = {
) => void, ) => void,
doResolveUris: (uris: Array<string>, cache: boolean) => void, doResolveUris: (uris: Array<string>, cache: boolean) => void,
doSuperChatList: (uri: string) => void, doSuperChatList: (uri: string) => void,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
export default function LivestreamChatLayout(props: Props) { export default function LivestreamChatLayout(props: Props) {
@ -68,6 +71,8 @@ export default function LivestreamChatLayout(props: Props) {
doCommentList, doCommentList,
doResolveUris, doResolveUris,
doSuperChatList, doSuperChatList,
doFetchUserMemberships,
claimsByUri,
} = props; } = props;
const isMobile = useIsMobile() && !isPopoutWindow; const isMobile = useIsMobile() && !isPopoutWindow;
@ -96,6 +101,22 @@ export default function LivestreamChatLayout(props: Props) {
superChatsByChronologicalOrder.sort((a, b) => b.timestamp - a.timestamp); superChatsByChronologicalOrder.sort((a, b) => b.timestamp - a.timestamp);
} }
// get commenter claim ids for checking premium status
const commenterClaimIds = commentsByChronologicalOrder.map(function(comment) {
return comment.channel_id;
});
// update premium status
const shouldFetchUserMemberships = true;
useGetUserMemberships(
shouldFetchUserMemberships,
commenterClaimIds,
claimsByUri,
doFetchUserMemberships,
[commentsByChronologicalOrder],
true,
);
const commentsToDisplay = const commentsToDisplay =
viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByChronologicalOrder; viewMode === VIEW_MODES.CHAT ? commentsByChronologicalOrder : superChatsByChronologicalOrder;
const commentsLength = commentsToDisplay && commentsToDisplay.length; const commentsLength = commentsToDisplay && commentsToDisplay.length;

View file

@ -1,16 +1,27 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectStakedLevelForChannelUri, selectClaimForUri, selectMyClaimIdsRaw } from 'redux/selectors/claims'; import {
selectStakedLevelForChannelUri,
selectClaimForUri,
selectMyClaimIdsRaw,
selectClaimsByUri,
selectOdyseeMembershipForChannelId,
} from 'redux/selectors/claims';
import LivestreamComment from './view'; import LivestreamComment from './view';
const select = (state, props) => { const select = (state, props) => {
const { uri, comment } = props; const { uri, comment } = props;
const { channel_url: authorUri } = comment; const { channel_url: authorUri, channel_id: channelId } = comment;
return { return {
claim: selectClaimForUri(state, uri), claim: selectClaimForUri(state, uri),
stakedLevel: selectStakedLevelForChannelUri(state, authorUri), stakedLevel: selectStakedLevelForChannelUri(state, authorUri),
myChannelIds: selectMyClaimIdsRaw(state), myChannelIds: selectMyClaimIdsRaw(state),
claimsByUri: selectClaimsByUri(state),
odyseeMembership: selectOdyseeMembershipForChannelId(state, channelId),
}; };
}; };
export default connect(select)(LivestreamComment); const perform = {};
export default connect(select, perform)(LivestreamComment);

View file

@ -17,6 +17,7 @@ import Icon from 'component/common/icon';
import MarkdownPreview from 'component/common/markdown-preview'; import MarkdownPreview from 'component/common/markdown-preview';
import OptimizedImage from 'component/optimizedImage'; import OptimizedImage from 'component/optimizedImage';
import React from 'react'; import React from 'react';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
comment: Comment, comment: Comment,
@ -27,8 +28,10 @@ type Props = {
myChannelIds: ?Array<string>, myChannelIds: ?Array<string>,
stakedLevel: number, stakedLevel: number,
isMobile?: boolean, isMobile?: boolean,
odyseeMembership: string,
handleDismissPin?: () => void, handleDismissPin?: () => void,
restoreScrollPos?: () => void, restoreScrollPos?: () => void,
claimsByUri: { [string]: any },
}; };
export default function LivestreamComment(props: Props) { export default function LivestreamComment(props: Props) {
@ -42,6 +45,7 @@ export default function LivestreamComment(props: Props) {
isMobile, isMobile,
handleDismissPin, handleDismissPin,
restoreScrollPos, restoreScrollPos,
odyseeMembership,
} = props; } = props;
const { const {
@ -98,10 +102,6 @@ export default function LivestreamComment(props: Props) {
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />} {supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />}
<div className="livestreamComment__info"> <div className="livestreamComment__info">
{isGlobalMod && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} size={16} />}
{isModerator && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_MOD} size={16} />}
{isStreamer && <CommentBadge label={__('Streamer')} icon={ICONS.BADGE_STREAMER} size={16} />}
<Button <Button
className={classnames('button--uri-indicator comment__author', { 'comment__author--creator': isStreamer })} className={classnames('button--uri-indicator comment__author', { 'comment__author--creator': isStreamer })}
target="_blank" target="_blank"
@ -117,6 +117,11 @@ export default function LivestreamComment(props: Props) {
</span> </span>
)} )}
{isGlobalMod && <CommentBadge label={__('Moderator')} icon={ICONS.BADGE_ADMIN} size={16} />}
{isModerator && <CommentBadge label={__('Admin')} icon={ICONS.BADGE_MOD} size={16} />}
{isStreamer && <CommentBadge label={__('Streamer')} icon={ICONS.BADGE_STREAMER} size={16} />}
<PremiumBadge membership={odyseeMembership} />
{/* Use key to force timestamp update */} {/* Use key to force timestamp update */}
<DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds /> <DateTime date={timePosted} timeAgo key={forceUpdate} genericSeconds />
@ -135,6 +140,7 @@ export default function LivestreamComment(props: Props) {
stakedLevel={stakedLevel} stakedLevel={stakedLevel}
disableTimestamps disableTimestamps
setUserMention={setUserMention} setUserMention={setUserMention}
hasMembership={odyseeMembership}
/> />
)} )}
</div> </div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import {
selectTitleForUri,
makeSelectClaimForUri,
selectClaimIsMineForUri,
selectFetchingMyChannels,
} from 'redux/selectors/claims';
import { selectClientSetting } from 'redux/selectors/settings';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { selectBalance, selectIsSendingSupport } from 'redux/selectors/wallet';
import { withRouter } from 'react-router';
import * as SETTINGS from 'constants/settings';
import WalletSendTip from './view';
const select = (state, props) => ({
activeChannelClaim: selectActiveChannelClaim(state),
balance: selectBalance(state),
claim: makeSelectClaimForUri(props.uri, false)(state),
claimIsMine: selectClaimIsMineForUri(state, props.uri),
fetchingChannels: selectFetchingMyChannels(state),
incognito: selectIncognito(state),
instantTipEnabled: selectClientSetting(state, SETTINGS.INSTANT_PURCHASE_ENABLED),
instantTipMax: selectClientSetting(state, SETTINGS.INSTANT_PURCHASE_MAX),
isPending: selectIsSendingSupport(state),
title: selectTitleForUri(state, props.uri),
});
const perform = (dispatch) => ({
doOpenModal,
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
});
export default withRouter(connect(select, perform)(WalletSendTip));

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,139 @@
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import Icon from 'component/common/icon';
import Button from 'component/button';
import React from 'react';
import AstronautAndFriends from './astronaut_n_friends.png';
import BadgePremium from './badge_premium.png';
import BadgePremiumPlus from './badge_premium-plus.png';
import OdyseePremium from './odysee_premium.png';
import I18nMessage from 'component/i18nMessage';
type Props = {
pageLocation: string,
currencyToUse: string,
};
export default function MembershipSplash(props: Props) {
const { pageLocation, currencyToUse } = props;
const premiumDisplayAmounts = {
eur: '€0.89',
usd: '99¢',
};
const premiumPlusDisplayAmounts = {
eur: '€2.68',
usd: '$2.99',
};
// const logo = <Icon className="header__logo" icon={ICONS.ODYSEE_WHITE_TEXT} />;
const earlyAcessInfo = (
<div className="membership-splash__info-content">
<Icon icon={ICONS.EARLY_ACCESS} />
{__('Exclusive and early access to features')}
</div>
);
const badgeInfo = (
<div className="membership-splash__info-content">
<Icon icon={ICONS.MEMBER_BADGE} />
{__('Badge on profile')}
</div>
);
const noAdsInfo = (
<div className="membership-splash__info-content">
<Icon icon={ICONS.NO_ADS} />
{__('No ads')}
</div>
);
return (
<div className="membership-splash">
<div className="membership-splash__banner">
<img src={AstronautAndFriends} />
<section className="membership-splash__title">
<section>
<img src={OdyseePremium} />
</section>
<section>
<I18nMessage
tokens={{ early_access: <b>{__('early access')}</b>, site_wide_badge: <b>{__('site-wide badge')}</b> }}
>
Get %early_access% features and a %site_wide_badge%
</I18nMessage>
</section>
</section>
</div>
<div className="membership-splash__info-wrapper">
<div className="membership-splash__info">
<I18nMessage>
Creating a revolutionary video platform for everyone is something we're proud to be doing, but it isn't
something that can happen without support. If you believe in Odysee's mission, please consider becoming a
Premium member. As a Premium member, you'll be helping us build the best platform in the universe and we'll
give you some cool perks!
</I18nMessage>
</div>
<div className="membership-splash__info">
<section className="membership-splash__info-header">
<div className="membership-splash__info-price">
<img src={BadgePremium} />
<section>
<I18nMessage
tokens={{
date_range: <div className="membership-splash__info-range">{__('A MONTH')}</div>,
currencyToUseString: premiumDisplayAmounts[currencyToUse],
}}
>
%currencyToUseString% %date_range%
</I18nMessage>
</section>
</div>
</section>
{badgeInfo}
{earlyAcessInfo}
<div className="membership-splash__info-button">
<Button
button="primary"
label={__('Join')}
navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}?interval=year&plan=Premium&pageLocation=${pageLocation}`}
/>
</div>
</div>
<div className="membership-splash__info">
<section className="membership-splash__info-header">
<div className="membership-splash__info-price">
<img src={BadgePremiumPlus} />
<section>
{premiumPlusDisplayAmounts[currencyToUse]}
<div className="membership-splash__info-range">{__('A MONTH')}</div>
</section>
</div>
</section>
{badgeInfo}
{earlyAcessInfo}
{noAdsInfo}
<div className="membership-splash__info-button">
<Button
button="primary"
label={__('Join')}
navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}?interval=year&plan=Premium%2b&pageLocation=${pageLocation}&`}
/>
</div>
</div>
</div>
</div>
);
}

View file

@ -32,7 +32,7 @@ import {
} from 'redux/selectors/app'; } from 'redux/selectors/app';
import { selectClientSetting } from 'redux/selectors/settings'; import { selectClientSetting } from 'redux/selectors/settings';
import { makeSelectFileRenderModeForUri } from 'redux/selectors/content'; import { makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import { selectUser } from 'redux/selectors/user'; import { selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import PublishForm from './view'; import PublishForm from './view';
const select = (state) => { const select = (state) => {
@ -65,6 +65,7 @@ const select = (state) => {
activeChannelStakedLevel: selectActiveChannelStakedLevel(state), activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
isClaimingInitialRewards: selectIsClaimingInitialRewards(state), isClaimingInitialRewards: selectIsClaimingInitialRewards(state),
hasClaimedInitialRewards: selectHasClaimedInitialRewards(state), hasClaimedInitialRewards: selectHasClaimedInitialRewards(state),
odyseeMembership: selectOdyseeMembershipName(state),
}; };
}; };

View file

@ -97,6 +97,7 @@ type Props = {
isClaimingInitialRewards: boolean, isClaimingInitialRewards: boolean,
claimInitialRewards: () => void, claimInitialRewards: () => void,
hasClaimedInitialRewards: boolean, hasClaimedInitialRewards: boolean,
odyseeMembership: string,
}; };
function PublishForm(props: Props) { function PublishForm(props: Props) {
@ -138,6 +139,7 @@ function PublishForm(props: Props) {
isClaimingInitialRewards, isClaimingInitialRewards,
claimInitialRewards, claimInitialRewards,
hasClaimedInitialRewards, hasClaimedInitialRewards,
odyseeMembership,
} = props; } = props;
const inEditMode = Boolean(editingURI); const inEditMode = Boolean(editingURI);
@ -146,11 +148,15 @@ function PublishForm(props: Props) {
const TYPE_PARAM = 'type'; const TYPE_PARAM = 'type';
const uploadType = urlParams.get(TYPE_PARAM); const uploadType = urlParams.get(TYPE_PARAM);
const _uploadType = uploadType && uploadType.toLowerCase(); const _uploadType = uploadType && uploadType.toLowerCase();
const userHasEnoughLBCForStreaming = activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM;
const enableLivestream = const enableLivestream =
ENABLE_NO_SOURCE_CLAIMS && ENABLE_NO_SOURCE_CLAIMS &&
user && user &&
!user.odysee_live_disabled && !user.odysee_live_disabled &&
(activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled); (userHasEnoughLBCForStreaming || user.odysee_live_enabled || odyseeMembership);
// $FlowFixMe // $FlowFixMe
const AVAILABLE_MODES = Object.values(PUBLISH_MODES).filter((mode) => { const AVAILABLE_MODES = Object.values(PUBLISH_MODES).filter((mode) => {
// $FlowFixMe // $FlowFixMe

View file

@ -2,7 +2,7 @@ import { connect } from 'react-redux';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri } from 'redux/selectors/claims';
import { doFetchRecommendedContent } from 'redux/actions/search'; import { doFetchRecommendedContent } from 'redux/actions/search';
import { selectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search'; import { selectRecommendedContentForUri, selectIsSearching } from 'redux/selectors/search';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import RecommendedContent from './view'; import RecommendedContent from './view';
const select = (state, props) => { const select = (state, props) => {
@ -14,7 +14,7 @@ const select = (state, props) => {
recommendedContentUris, recommendedContentUris,
nextRecommendedUri, nextRecommendedUri,
isSearching: selectIsSearching(state), isSearching: selectIsSearching(state),
isAuthenticated: selectUserVerifiedEmail(state), userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}; };
}; };

View file

@ -23,8 +23,10 @@ type Props = {
nextRecommendedUri: string, nextRecommendedUri: string,
isSearching: boolean, isSearching: boolean,
doFetchRecommendedContent: (string) => void, doFetchRecommendedContent: (string) => void,
isAuthenticated: boolean,
claim: ?StreamClaim, claim: ?StreamClaim,
claimId: string,
metadata: any,
userHasPremiumPlus: boolean,
}; };
export default React.memo<Props>(function RecommendedContent(props: Props) { export default React.memo<Props>(function RecommendedContent(props: Props) {
@ -34,12 +36,12 @@ export default React.memo<Props>(function RecommendedContent(props: Props) {
recommendedContentUris, recommendedContentUris,
nextRecommendedUri, nextRecommendedUri,
isSearching, isSearching,
isAuthenticated,
claim, claim,
userHasPremiumPlus,
} = props; } = props;
const claimId: ?string = claim && claim.claim_id; const claimId: ?string = claim && claim.claim_id;
const injectAds = SHOW_ADS && IS_WEB && !isAuthenticated; const injectAds = SHOW_ADS && IS_WEB && !userHasPremiumPlus;
function claimContainsBlockedWords(claim: ?StreamClaim) { function claimContainsBlockedWords(claim: ?StreamClaim) {
if (BLOCKED_WORDS) { if (BLOCKED_WORDS) {
@ -177,7 +179,6 @@ function areEqual(prevProps: Props, nextProps: Props) {
if ( if (
a.uri !== b.uri || a.uri !== b.uri ||
a.nextRecommendedUri !== b.nextRecommendedUri || a.nextRecommendedUri !== b.nextRecommendedUri ||
a.isAuthenticated !== b.isAuthenticated ||
a.isSearching !== b.isSearching || a.isSearching !== b.isSearching ||
(a.recommendedContentUris && !b.recommendedContentUris) || (a.recommendedContentUris && !b.recommendedContentUris) ||
(!a.recommendedContentUris && b.recommendedContentUris) || (!a.recommendedContentUris && b.recommendedContentUris) ||

View file

@ -72,6 +72,9 @@ const LiveStreamSetupPage = lazyImport(() => import('page/livestreamSetup' /* we
const LivestreamCurrentPage = lazyImport(() => const LivestreamCurrentPage = lazyImport(() =>
import('page/livestreamCurrent' /* webpackChunkName: "livestreamCurrent" */) import('page/livestreamCurrent' /* webpackChunkName: "livestreamCurrent" */)
); );
const OdyseeMembershipPage = lazyImport(() =>
import('page/odyseeMembership' /* webpackChunkName: "odyseeMembership" */)
);
const OwnComments = lazyImport(() => import('page/ownComments' /* webpackChunkName: "ownComments" */)); const OwnComments = lazyImport(() => import('page/ownComments' /* webpackChunkName: "ownComments" */));
const PasswordResetPage = lazyImport(() => import('page/passwordReset' /* webpackChunkName: "passwordReset" */)); const PasswordResetPage = lazyImport(() => import('page/passwordReset' /* webpackChunkName: "passwordReset" */));
const PasswordSetPage = lazyImport(() => import('page/passwordSet' /* webpackChunkName: "passwordSet" */)); const PasswordSetPage = lazyImport(() => import('page/passwordSet' /* webpackChunkName: "passwordSet" */));
@ -365,6 +368,7 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} /> <PrivateRoute {...props} path={`/$/${PAGES.NOTIFICATIONS}`} component={NotificationsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} /> <PrivateRoute {...props} path={`/$/${PAGES.AUTH_WALLET_PASSWORD}`} component={SignInWalletPasswordPage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} /> <PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_OWN_COMMENTS}`} component={OwnComments} />
<PrivateRoute {...props} path={`/$/${PAGES.ODYSEE_MEMBERSHIP}`} component={OdyseeMembershipPage} />
<Route path={`/$/${PAGES.POPOUT}/:channelName/:streamName`} component={PopoutChatPage} /> <Route path={`/$/${PAGES.POPOUT}/:channelName/:streamName`} component={PopoutChatPage} />

View file

@ -1,15 +1,14 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { doFetchLastActiveSubs } from 'redux/actions/subscriptions'; import { doFetchLastActiveSubs } from 'redux/actions/subscriptions';
import { selectActiveChannelStakedLevel } from 'redux/selectors/app';
import { selectLastActiveSubscriptions, selectSubscriptions } from 'redux/selectors/subscriptions'; import { selectLastActiveSubscriptions, selectSubscriptions } from 'redux/selectors/subscriptions';
import { doClearClaimSearch } from 'redux/actions/claims'; import { doClearClaimSearch } from 'redux/actions/claims';
import { doClearPurchasedUriSuccess } from 'redux/actions/file'; import { doClearPurchasedUriSuccess } from 'redux/actions/file';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { selectUserVerifiedEmail, selectUser } from 'redux/selectors/user'; import { selectUserVerifiedEmail, selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import { selectHomepageData, selectWildWestDisabled } from 'redux/selectors/settings'; import { selectHomepageData, selectWildWestDisabled } from 'redux/selectors/settings';
import { doSignOut } from 'redux/actions/app'; import { doSignOut } from 'redux/actions/app';
import { selectUnseenNotificationCount } from 'redux/selectors/notifications'; import { selectUnseenNotificationCount } from 'redux/selectors/notifications';
import { selectPurchaseUriSuccess } from 'redux/selectors/claims'; import { selectPurchaseUriSuccess, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import SideNavigation from './view'; import SideNavigation from './view';
@ -22,8 +21,9 @@ const select = (state) => ({
unseenCount: selectUnseenNotificationCount(state), unseenCount: selectUnseenNotificationCount(state),
user: selectUser(state), user: selectUser(state),
homepageData: selectHomepageData(state), homepageData: selectHomepageData(state),
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
wildWestDisabled: selectWildWestDisabled(state), wildWestDisabled: selectWildWestDisabled(state),
odyseeMembership: selectOdyseeMembershipName(state),
odyseeMembershipByUri: (uri) => selectOdyseeMembershipForUri(state, uri),
}); });
export default connect(select, { export default connect(select, {

View file

@ -15,7 +15,8 @@ import I18nMessage from 'component/i18nMessage';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import { useIsMobile, useIsLargeScreen, isTouch } from 'effects/use-screensize'; import { useIsMobile, useIsLargeScreen, isTouch } from 'effects/use-screensize';
import { GetLinksData } from 'util/buildHomepage'; import { GetLinksData } from 'util/buildHomepage';
import { DOMAIN, ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS, CHANNEL_STAKED_LEVEL_LIVESTREAM } from 'config'; import { DOMAIN, ENABLE_UI_NOTIFICATIONS, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import PremiumBadge from 'component/common/premium-badge';
const touch = isTouch(); const touch = isTouch();
@ -70,6 +71,13 @@ const PLAYLISTS = {
hideForUnauth: true, hideForUnauth: true,
}; };
const PREMIUM = {
title: 'Premium',
link: `/$/${PAGES.ODYSEE_MEMBERSHIP}`,
icon: ICONS.UPGRADE,
hideForUnauth: true,
};
const UNAUTH_LINKS: Array<SideNavLink> = [ const UNAUTH_LINKS: Array<SideNavLink> = [
{ {
title: 'Log In', title: 'Log In',
@ -118,9 +126,10 @@ type Props = {
doClearPurchasedUriSuccess: () => void, doClearPurchasedUriSuccess: () => void,
user: ?User, user: ?User,
homepageData: any, homepageData: any,
activeChannelStakedLevel: number,
wildWestDisabled: boolean, wildWestDisabled: boolean,
doClearClaimSearch: () => void, doClearClaimSearch: () => void,
odyseeMembership: string,
odyseeMembershipByUri: (uri: string) => string,
doFetchLastActiveSubs: (force?: boolean, count?: number) => void, doFetchLastActiveSubs: (force?: boolean, count?: number) => void,
}; };
@ -140,9 +149,10 @@ function SideNavigation(props: Props) {
homepageData, homepageData,
user, user,
followedTags, followedTags,
activeChannelStakedLevel,
wildWestDisabled, wildWestDisabled,
doClearClaimSearch, doClearClaimSearch,
odyseeMembership,
odyseeMembershipByUri,
doFetchLastActiveSubs, doFetchLastActiveSubs,
} = props; } = props;
@ -228,12 +238,7 @@ function SideNavigation(props: Props) {
const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui); const notificationsEnabled = ENABLE_UI_NOTIFICATIONS || (user && user.experimental_ui);
const isAuthenticated = Boolean(email); const isAuthenticated = Boolean(email);
const livestreamEnabled = Boolean( const livestreamEnabled = Boolean(ENABLE_NO_SOURCE_CLAIMS && user && !user.odysee_live_disabled);
ENABLE_NO_SOURCE_CLAIMS &&
user &&
!user.odysee_live_disabled &&
(activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM || user.odysee_live_enabled)
);
const [pulseLibrary, setPulseLibrary] = React.useState(false); const [pulseLibrary, setPulseLibrary] = React.useState(false);
const [expandTags, setExpandTags] = React.useState(false); const [expandTags, setExpandTags] = React.useState(false);
@ -325,7 +330,11 @@ function SideNavigation(props: Props) {
</li> </li>
)} )}
{displayedSubscriptions.map((subscription) => ( {displayedSubscriptions.map((subscription) => (
<SubscriptionListItem key={subscription.uri} subscription={subscription} /> <SubscriptionListItem
key={subscription.uri}
subscription={subscription}
odyseeMembershipByUri={odyseeMembershipByUri}
/>
))} ))}
{!!subscriptionFilter && !displayedSubscriptions.length && ( {!!subscriptionFilter && !displayedSubscriptions.length && (
<li> <li>
@ -494,6 +503,7 @@ function SideNavigation(props: Props) {
{getLink(getHomeButton(doClearClaimSearch))} {getLink(getHomeButton(doClearClaimSearch))}
{getLink(RECENT_FROM_FOLLOWING)} {getLink(RECENT_FROM_FOLLOWING)}
{getLink(PLAYLISTS)} {getLink(PLAYLISTS)}
{!odyseeMembership && getLink(PREMIUM)}
</ul> </ul>
<ul <ul
@ -533,8 +543,17 @@ function SideNavigation(props: Props) {
); );
} }
function SubscriptionListItem({ subscription }: { subscription: Subscription }) { type SubItemProps = {
subscription: Subscription,
odyseeMembershipByUri: (uri: string) => string,
}
function SubscriptionListItem(props: SubItemProps) {
const { subscription, odyseeMembershipByUri } = props;
const { uri, channelName } = subscription; const { uri, channelName } = subscription;
const membership = odyseeMembershipByUri(uri);
return ( return (
<li className="navigation-link__wrapper navigation__subscription"> <li className="navigation-link__wrapper navigation__subscription">
<Button <Button
@ -547,6 +566,7 @@ function SubscriptionListItem({ subscription }: { subscription: Subscription })
<ClaimPreviewTitle uri={uri} /> <ClaimPreviewTitle uri={uri} />
<span dir="auto" className="channel-name"> <span dir="auto" className="channel-name">
{channelName} {channelName}
<PremiumBadge membership={membership} />
</span> </span>
</div> </div>
</Button> </Button>

View file

@ -1,5 +1,5 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectClaimForUri } from 'redux/selectors/claims'; import { selectClaimForUri, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import TextareaSuggestionsItem from './view'; import TextareaSuggestionsItem from './view';
import { formatLbryChannelName } from 'util/url'; import { formatLbryChannelName } from 'util/url';
import { getClaimTitle } from 'util/claim'; import { getClaimTitle } from 'util/claim';
@ -12,6 +12,7 @@ const select = (state, props) => {
return { return {
claimLabel: claim && formatLbryChannelName(claim.canonical_url), claimLabel: claim && formatLbryChannelName(claim.canonical_url),
claimTitle: claim && getClaimTitle(claim), claimTitle: claim && getClaimTitle(claim),
odyseeMembershipByUri: selectOdyseeMembershipForUri(state, uri),
}; };
}; };

View file

@ -1,16 +1,18 @@
// @flow // @flow
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import React from 'react'; import React from 'react';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
claimLabel?: string, claimLabel?: string,
claimTitle?: string, claimTitle?: string,
emote?: any, emote?: any,
uri?: string, uri?: string,
odyseeMembershipByUri: ?string,
}; };
export default function TextareaSuggestionsItem(props: Props) { export default function TextareaSuggestionsItem(props: Props) {
const { claimLabel, claimTitle, emote, uri, ...autocompleteProps } = props; const { claimLabel, claimTitle, emote, uri, odyseeMembershipByUri, ...autocompleteProps } = props;
if (emote) { if (emote) {
const { name: value, url, unicode } = emote; const { name: value, url, unicode } = emote;
@ -37,7 +39,10 @@ export default function TextareaSuggestionsItem(props: Props) {
<div className="textarea-suggestion__label"> <div className="textarea-suggestion__label">
<span className="textarea-suggestion__title">{claimTitle || value}</span> <span className="textarea-suggestion__title">{claimTitle || value}</span>
<span className="textarea-suggestion__value">{value}</span> <span className="textarea-suggestion__value">
{value}
<PremiumBadge membership={odyseeMembershipByUri} />
</span>
</div> </div>
</div> </div>
); );

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { normalizeURI } from 'util/lbryURI'; import { normalizeURI } from 'util/lbryURI';
import { doResolveUri } from 'redux/actions/claims'; import { doResolveUri } from 'redux/actions/claims';
import { selectIsUriResolving, selectClaimForUri } from 'redux/selectors/claims'; import { selectIsUriResolving, selectClaimForUri, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import UriIndicator from './view'; import UriIndicator from './view';
const select = (state, props) => { const select = (state, props) => {
@ -14,6 +14,7 @@ const select = (state, props) => {
claim: selectClaimForUri(state, props.uri), claim: selectClaimForUri(state, props.uri),
isResolvingUri: selectIsUriResolving(state, props.uri), isResolvingUri: selectIsUriResolving(state, props.uri),
uri, uri,
odyseeMembership: selectOdyseeMembershipForUri(state, props.uri),
}; };
}; };

View file

@ -3,6 +3,7 @@ import type { Node } from 'react';
import React from 'react'; import React from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import Button from 'component/button'; import Button from 'component/button';
import PremiumBadge from 'component/common/premium-badge';
import { stripLeadingAtSign } from 'util/string'; import { stripLeadingAtSign } from 'util/string';
type ChannelInfo = { uri: string, name: string, title: string }; type ChannelInfo = { uri: string, name: string, title: string };
@ -17,10 +18,13 @@ type Props = {
inline?: boolean, inline?: boolean,
showAtSign?: boolean, showAtSign?: boolean,
className?: string, className?: string,
showMemberBadge?: boolean,
children: ?Node, // to allow for other elements to be nested within the UriIndicator (commit: 1e82586f). children: ?Node, // to allow for other elements to be nested within the UriIndicator (commit: 1e82586f).
// --- redux --- // --- redux ---
claim: ?Claim, claim: ?Claim,
isResolvingUri: boolean, isResolvingUri: boolean,
odyseeMembership: string,
comment?: boolean,
resolveUri: (string) => void, resolveUri: (string) => void,
}; };
@ -90,6 +94,9 @@ class UriIndicator extends React.PureComponent<Props> {
hideAnonymous = false, hideAnonymous = false,
showAtSign, showAtSign,
className, className,
odyseeMembership,
comment,
showMemberBadge = true,
} = this.props; } = this.props;
if (!channelInfo && !claim) { if (!channelInfo && !claim) {
@ -119,7 +126,8 @@ class UriIndicator extends React.PureComponent<Props> {
const inner = ( const inner = (
<span dir="auto" className={classnames('channel-name', { 'channel-name--inline': inline })}> <span dir="auto" className={classnames('channel-name', { 'channel-name--inline': inline })}>
{showAtSign ? channelName : stripLeadingAtSign(channelTitle)} <p>{showAtSign ? channelName : stripLeadingAtSign(channelTitle)}</p>
{!comment && showMemberBadge && <PremiumBadge membership={odyseeMembership} />}
</span> </span>
); );

View file

@ -1,10 +1,15 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectClaimForUri, selectIsUriResolving } from 'redux/selectors/claims'; import { selectClaimForUri, selectIsUriResolving, selectOdyseeMembershipForUri } from 'redux/selectors/claims';
import WunderbarSuggestion from './view'; import WunderbarSuggestion from './view';
const select = (state, props) => ({ const select = (state, props) => {
claim: selectClaimForUri(state, props.uri), const { uri } = props;
isResolvingUri: selectIsUriResolving(state, props.uri),
}); return {
claim: selectClaimForUri(state, uri),
isResolvingUri: selectIsUriResolving(state, uri),
odyseeMembershipByUri: selectOdyseeMembershipForUri(state, uri),
};
};
export default connect(select)(WunderbarSuggestion); export default connect(select)(WunderbarSuggestion);

View file

@ -6,15 +6,17 @@ import FileThumbnail from 'component/fileThumbnail';
import ChannelThumbnail from 'component/channelThumbnail'; import ChannelThumbnail from 'component/channelThumbnail';
import FileProperties from 'component/previewOverlayProperties'; import FileProperties from 'component/previewOverlayProperties';
import ClaimProperties from 'component/claimProperties'; import ClaimProperties from 'component/claimProperties';
import PremiumBadge from 'component/common/premium-badge';
type Props = { type Props = {
claim: ?Claim, claim: ?Claim,
uri: string, uri: string,
isResolvingUri: boolean, isResolvingUri: boolean,
odyseeMembershipByUri: ?string,
}; };
export default function WunderbarSuggestion(props: Props) { export default function WunderbarSuggestion(props: Props) {
const { claim, uri, isResolvingUri } = props; const { claim, uri, isResolvingUri, odyseeMembershipByUri } = props;
if (isResolvingUri) { if (isResolvingUri) {
return ( return (
@ -61,6 +63,7 @@ export default function WunderbarSuggestion(props: Props) {
<div className="wunderbar__suggestion-title">{claim.value.title}</div> <div className="wunderbar__suggestion-title">{claim.value.title}</div>
<div className="wunderbar__suggestion-name"> <div className="wunderbar__suggestion-name">
{isChannel ? claim.name : (claim.signing_channel && claim.signing_channel.name) || __('Anonymous')} {isChannel ? claim.name : (claim.signing_channel && claim.signing_channel.name) || __('Anonymous')}
<PremiumBadge membership={odyseeMembershipByUri} />
</div> </div>
</span> </span>
</div> </div>

View file

@ -298,6 +298,8 @@ export const USER_SET_REFERRER_SUCCESS = 'USER_SET_REFERRER_SUCCESS';
export const USER_SET_REFERRER_FAILURE = 'USER_SET_REFERRER_FAILURE'; export const USER_SET_REFERRER_FAILURE = 'USER_SET_REFERRER_FAILURE';
export const USER_SET_REFERRER_RESET = 'USER_SET_REFERRER_RESET'; export const USER_SET_REFERRER_RESET = 'USER_SET_REFERRER_RESET';
export const USER_EMAIL_VERIFY_RETRY = 'USER_EMAIL_VERIFY_RETRY'; export const USER_EMAIL_VERIFY_RETRY = 'USER_EMAIL_VERIFY_RETRY';
export const ADD_ODYSEE_MEMBERSHIP_DATA = 'ADD_ODYSEE_MEMBERSHIP_DATA';
export const ADD_CLAIMIDS_MEMBERSHIP_DATA = 'ADD_CLAIMIDS_MEMBERSHIP_DATA';
// Rewards // Rewards
export const FETCH_REWARDS_STARTED = 'FETCH_REWARDS_STARTED'; export const FETCH_REWARDS_STARTED = 'FETCH_REWARDS_STARTED';

View file

@ -178,6 +178,7 @@ export const CONTENT = 'Content';
export const STAR = 'star'; export const STAR = 'star';
export const MUSIC = 'MusicCategory'; export const MUSIC = 'MusicCategory';
export const BADGE_MOD = 'BadgeMod'; export const BADGE_MOD = 'BadgeMod';
export const BADGE_ADMIN = 'BadgeAdmin';
export const BADGE_STREAMER = 'BadgeStreamer'; export const BADGE_STREAMER = 'BadgeStreamer';
export const REPLAY = 'Replay'; export const REPLAY = 'Replay';
export const REPEAT = 'Repeat'; export const REPEAT = 'Repeat';
@ -195,6 +196,12 @@ export const ODYSEE_LOGO = 'OdyseeLogo';
export const ODYSEE_WHITE_TEXT = 'OdyseeLogoWhiteText'; export const ODYSEE_WHITE_TEXT = 'OdyseeLogoWhiteText';
export const ODYSEE_DARK_TEXT = 'OdyseeLogoDarkText'; export const ODYSEE_DARK_TEXT = 'OdyseeLogoDarkText';
export const FEATURED = 'Featured'; export const FEATURED = 'Featured';
export const EARLY_ACCESS = 'EarlyAccess';
export const MEMBER_BADGE = 'MemberBadge';
export const NO_ADS = 'NoAds';
export const PREMIUM = 'Premium';
export const PREMIUM_PLUS = 'PremiumPlus';
export const UPGRADE = 'Upgrade';
export const DISMISS_ALL = 'DismissAll'; export const DISMISS_ALL = 'DismissAll';
export const SUBMIT = 'Submit'; export const SUBMIT = 'Submit';
export const FILTERED_BY_LANG = 'FilteredByLang'; export const FILTERED_BY_LANG = 'FilteredByLang';

View file

@ -47,3 +47,5 @@ export const COLLECTION_ADD = 'collection_add';
export const COLLECTION_DELETE = 'collection_delete'; export const COLLECTION_DELETE = 'collection_delete';
export const CONFIRM_REMOVE_CARD = 'CONFIRM_REMOVE_CARD'; export const CONFIRM_REMOVE_CARD = 'CONFIRM_REMOVE_CARD';
export const CONFIRM_REMOVE_COMMENT = 'CONFIRM_REMOVE_COMMENT'; export const CONFIRM_REMOVE_COMMENT = 'CONFIRM_REMOVE_COMMENT';
export const CONFIRM_ODYSEE_MEMBERSHIP = 'CONFIRM_ODYSEE_MEMBERSHIP';
export const MEMBERSHIP_SPLASH = 'MEMBERSHIP_SPLASH';

View file

@ -86,4 +86,5 @@ exports.LIVESTREAM = 'livestream';
exports.LIVESTREAM_CURRENT = 'live'; exports.LIVESTREAM_CURRENT = 'live';
exports.GENERAL = 'general'; exports.GENERAL = 'general';
exports.LIST = 'list'; exports.LIST = 'list';
exports.ODYSEE_MEMBERSHIP = 'membership';
exports.POPOUT = 'popout'; exports.POPOUT = 'popout';

View file

@ -1,10 +1,17 @@
// @flow // @flow
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
/**
*
* @param {boolean} shouldFetch - Whether to get the views, not needed for some pages
* @param {array} uris - Array of the LBRY uris of content to fetch views for
* @param {object} claimsByUri - Function to get claimIds from claim uris
* @param {function} doFetchViewCount - Get views account per a string of comma separated Claim Ids
*/
export default function useFetchViewCount( export default function useFetchViewCount(
shouldFetch: ?boolean, shouldFetch: ?boolean,
uris: Array<string>, uris: Array<string>,
claimsByUri: any, claimsByUri: {},
doFetchViewCount: (string) => void doFetchViewCount: (string) => void
) { ) {
const [fetchedUris, setFetchedUris] = useState([]); const [fetchedUris, setFetchedUris] = useState([]);

View file

@ -0,0 +1,62 @@
// @flow
import { useState, useEffect } from 'react';
import { getChannelFromClaim } from 'util/claim';
export default function useGetUserMemberships(
shouldFetchUserMemberships: ?boolean,
arrayOfContentUris: ?Array<string>,
convertClaimUrlsToIds: any,
doFetchUserMemberships: (string) => void, // fetch membership values and save in redux
dependency?: any,
alreadyClaimIds?: boolean,
) {
const [userMemberships, setUserMemberships] = useState([]);
useEffect(() => {
if (shouldFetchUserMemberships && arrayOfContentUris && arrayOfContentUris.length > 0) {
const urisToFetch = arrayOfContentUris;
let claimIds;
if (!alreadyClaimIds) {
claimIds = urisToFetch.map((uri) => {
// get claim id from array
const claimUrlsToId = convertClaimUrlsToIds[uri];
if (claimUrlsToId) {
const { claim_id: claimId } = getChannelFromClaim(claimUrlsToId) || {};
return claimId;
}
});
} else {
claimIds = arrayOfContentUris;
}
const dedupedChannelIds = [...new Set(claimIds)];
const channelClaimIdsToCheck = dedupedChannelIds.filter(
// not in fetched claims but exists in array
(claimId) => claimId && !userMemberships.includes(claimId)
);
const channelsToFetch = channelClaimIdsToCheck.filter(
// not in fetched claims but exists in array
(uri) => uri && !userMemberships.includes(uri)
);
const commaSeparatedStringOfIds = channelsToFetch.join(',');
if (channelsToFetch && channelsToFetch.length > 0) {
// hit membership/check and save it in redux
const combinedArray = [...userMemberships, ...channelsToFetch];
setUserMemberships(combinedArray);
if (doFetchUserMemberships) {
doFetchUserMemberships(commaSeparatedStringOfIds);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependency || [arrayOfContentUris]);
}

View file

@ -0,0 +1,11 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import { doToast } from 'redux/actions/notifications';
import ModalConfirmOdyseeMembership from './view';
const perform = (dispatch) => ({
closeModal: () => dispatch(doHideModal()),
doToast: (params) => dispatch(doToast(params)),
});
export default connect(null, perform)(ModalConfirmOdyseeMembership);

View file

@ -0,0 +1,169 @@
// @flow
import React from 'react';
import { Modal } from 'modal/modal';
import Card from 'component/common/card';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
import { Lbryio } from 'lbryinc';
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
type Props = {
closeModal: () => void,
paymentMethodId: string,
setAsConfirmingCard: () => void, // ?
hasMembership: boolean, // user already has purchased --> invoke Cancel then
membershipId: string,
populateMembershipData: () => void,
userChannelClaimId: string,
userChannelName: string,
priceId: string,
purchaseString: string,
plan: string,
setMembershipOptions: (any) => void,
doToast: ({ message: string }) => void,
updateUserOdyseeMembershipStatus: ({}) => void,
user: ?User,
};
export default function ConfirmOdyseeMembershipPurchase(props: Props) {
const {
closeModal,
membershipId,
populateMembershipData,
userChannelClaimId,
userChannelName,
hasMembership,
priceId,
purchaseString,
plan,
setMembershipOptions,
doToast,
updateUserOdyseeMembershipStatus,
user,
} = props;
const [waitingForBackend, setWaitingForBackend] = React.useState();
const [statusText, setStatusText] = React.useState();
async function purchaseMembership() {
try {
setWaitingForBackend(true);
setStatusText(__('Completing your purchase...'));
// show the memberships the user is subscribed to
await Lbryio.call(
'membership',
'buy',
{
environment: stripeEnvironment,
membership_id: membershipId,
channel_id: userChannelClaimId,
channel_name: userChannelName,
price_id: priceId,
},
'post'
);
// cleary query params
// $FlowFixMe
let newURL = location.href.split('?')[0];
window.history.pushState('object', document.title, newURL);
setStatusText(__('Membership was successful'));
// populate the new data and update frontend
await populateMembershipData();
// clear the other membership options after making a purchase
setMembershipOptions(false);
if (user) updateUserOdyseeMembershipStatus(user);
closeModal();
} catch (err) {
const errorMessage = err.message;
const subscriptionFailedBackendError = 'failed to create subscription with default card';
// wait a bit to show the message so it's not jarring for the user
let errorMessageTimeout = 1150;
// don't do an error delay if there's already a network error
if (errorMessage === subscriptionFailedBackendError) {
errorMessageTimeout = 0;
}
setTimeout(function () {
const genericErrorMessage = __(
"Sorry, your purchase wasn't able to completed. Please contact support for possible next steps"
);
doToast({
message: genericErrorMessage,
isError: true,
});
closeModal();
}, errorMessageTimeout);
console.log(err);
}
}
// Cancel
async function cancelMembership() {
try {
setWaitingForBackend(true);
setStatusText(__('Canceling your membership...'));
// show the memberships the user is subscribed to
await Lbryio.call(
'membership',
'cancel',
{
environment: stripeEnvironment,
membership_id: membershipId,
},
'post'
);
setStatusText(__('Membership successfully canceled'));
// populate the new data and update frontend
await populateMembershipData();
closeModal();
} catch (err) {
console.log(err);
}
}
return (
<Modal ariaHideApp={false} isOpen contentLabel={'Confirm Membership Purchase'} type="card" onAborted={closeModal}>
<Card
className="stripe__confirm-remove-membership"
title={hasMembership ? __('Confirm Membership Cancellation') : __(`Confirm %plan% Membership`, { plan })}
subtitle={purchaseString}
actions={
<div className="section__actions">
{!waitingForBackend ? (
<>
<Button
className="stripe__confirm-remove-card"
button="primary"
icon={ICONS.FINANCE}
label={hasMembership ? __('Confirm Cancellation') : __('Confirm Purchase')}
onClick={() => (hasMembership ? cancelMembership() : purchaseMembership())}
/>
<Button button="link" label={__('Cancel')} onClick={closeModal} />
</>
) : (
<h1 style={{ fontSize: '18px' }}>{statusText}</h1>
)}
</div>
}
/>
</Modal>
);
}

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { doHideModal } from 'redux/actions/app';
import ModalMembershipSplash from './view';
const perform = (dispatch) => ({
closeModal: () => dispatch(doHideModal()),
});
export default connect(null, perform)(ModalMembershipSplash);

View file

@ -0,0 +1,25 @@
// @flow
import React from 'react';
import { Modal } from 'modal/modal';
import MembershipSplash from 'component/membershipSplash';
type Props = {
closeModal: () => void,
uri: string,
claimIsMine: boolean,
isSupport: boolean,
};
class ModalSendTip extends React.PureComponent<Props> {
render() {
const { closeModal, uri, claimIsMine } = this.props;
return (
<Modal onAborted={closeModal} isOpen type="card">
<MembershipSplash uri={uri} claimIsMine={claimIsMine} onCancel={closeModal} />
</Modal>
);
}
}
export default ModalSendTip;

View file

@ -65,6 +65,12 @@ const ModalPublishPreview = lazyImport(() =>
import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */) import('modal/modalPublishPreview' /* webpackChunkName: "modalPublishPreview" */)
); );
const ModalRemoveCard = lazyImport(() => import('modal/modalRemoveCard' /* webpackChunkName: "modalRemoveCard" */)); const ModalRemoveCard = lazyImport(() => import('modal/modalRemoveCard' /* webpackChunkName: "modalRemoveCard" */));
const ModalConfirmOdyseeMembership = lazyImport(() =>
import('modal/modalConfirmOdyseeMembership' /* webpackChunkName: "modalConfirmOdyseeMembership" */)
);
const OdyseeMembershipSplash = lazyImport(() =>
import('modal/modalMembershipSplash' /* webpackChunkName: "modalMembershipSplash" */)
);
const ModalRemoveComment = lazyImport(() => const ModalRemoveComment = lazyImport(() =>
import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */) import('modal/modalRemoveComment' /* webpackChunkName: "modalRemoveComment" */)
); );
@ -126,6 +132,8 @@ function getModal(id) {
return ModalPhoneCollection; return ModalPhoneCollection;
case MODALS.SEND_TIP: case MODALS.SEND_TIP:
return ModalSendTip; return ModalSendTip;
case MODALS.MEMBERSHIP_SPLASH:
return OdyseeMembershipSplash;
case MODALS.SOCIAL_SHARE: case MODALS.SOCIAL_SHARE:
return ModalSocialShare; return ModalSocialShare;
case MODALS.PUBLISH: case MODALS.PUBLISH:
@ -180,6 +188,8 @@ function getModal(id) {
return ModalDeleteCollection; return ModalDeleteCollection;
case MODALS.CONFIRM_REMOVE_CARD: case MODALS.CONFIRM_REMOVE_CARD:
return ModalRemoveCard; return ModalRemoveCard;
case MODALS.CONFIRM_ODYSEE_MEMBERSHIP:
return ModalConfirmOdyseeMembership;
case MODALS.CONFIRM_REMOVE_COMMENT: case MODALS.CONFIRM_REMOVE_COMMENT:
return ModalRemoveComment; return ModalRemoveComment;
default: default:

View file

@ -237,7 +237,14 @@ function ChannelPage(props: Props) {
{cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />} {cover && <img className={classnames('channel-cover__custom')} src={PlaceholderTx} />}
{cover && <OptimizedImage className={classnames('channel-cover__custom')} src={cover} objectFit="cover" />} {cover && <OptimizedImage className={classnames('channel-cover__custom')} src={cover} objectFit="cover" />}
<div className="channel__primary-info"> <div className="channel__primary-info">
<ChannelThumbnail className="channel__thumbnail--channel-page" uri={uri} allowGifs /> <ChannelThumbnail
className="channel__thumbnail--channel-page"
uri={uri}
allowGifs
showMemberBadge
isChannel
hideStakedIndicator
/>
<h1 className="channel__title"> <h1 className="channel__title">
<TruncatedText lines={2} showTooltip> <TruncatedText lines={2} showTooltip>
{title || (channelName && '@' + channelName)} {title || (channelName && '@' + channelName)}

View file

@ -4,7 +4,9 @@ import {
selectFetchingMyChannels, selectFetchingMyChannels,
makeSelectClaimIsPending, makeSelectClaimIsPending,
selectPendingIds, selectPendingIds,
selectClaimsByUri,
} from 'redux/selectors/claims'; } from 'redux/selectors/claims';
import { doFetchUserMemberships } from 'redux/actions/user';
import { doFetchChannelListMine } from 'redux/actions/claims'; import { doFetchChannelListMine } from 'redux/actions/claims';
import { doSetActiveChannel } from 'redux/actions/app'; import { doSetActiveChannel } from 'redux/actions/app';
import { selectYoutubeChannels } from 'redux/selectors/user'; import { selectYoutubeChannels } from 'redux/selectors/user';
@ -30,12 +32,14 @@ const select = (state) => {
fetchingChannels: selectFetchingMyChannels(state), fetchingChannels: selectFetchingMyChannels(state),
youtubeChannels: selectYoutubeChannels(state), youtubeChannels: selectYoutubeChannels(state),
pendingChannels, pendingChannels,
claimsByUri: selectClaimsByUri(state),
}; };
}; };
const perform = (dispatch) => ({ const perform = (dispatch) => ({
fetchChannelListMine: () => dispatch(doFetchChannelListMine()), fetchChannelListMine: () => dispatch(doFetchChannelListMine()),
doSetActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)), doSetActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)),
doFetchUserMemberships: (claimIds) => dispatch(doFetchUserMemberships(claimIds)),
}); });
export default connect(select, perform)(ChannelsPage); export default connect(select, perform)(ChannelsPage);

View file

@ -12,6 +12,7 @@ import LbcSymbol from 'component/common/lbc-symbol';
import * as PAGES from 'constants/pages'; import * as PAGES from 'constants/pages';
import HelpLink from 'component/common/help-link'; import HelpLink from 'component/common/help-link';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import useGetUserMemberships from 'effects/use-get-user-memberships';
type Props = { type Props = {
channelUrls: Array<string>, channelUrls: Array<string>,
@ -20,6 +21,8 @@ type Props = {
youtubeChannels: ?Array<any>, youtubeChannels: ?Array<any>,
doSetActiveChannel: (string) => void, doSetActiveChannel: (string) => void,
pendingChannels: Array<string>, pendingChannels: Array<string>,
claimsByUri: { [string]: any },
doFetchUserMemberships: (claimIdCsv: string) => void,
}; };
export default function ChannelsPage(props: Props) { export default function ChannelsPage(props: Props) {
@ -30,10 +33,15 @@ export default function ChannelsPage(props: Props) {
youtubeChannels, youtubeChannels,
doSetActiveChannel, doSetActiveChannel,
pendingChannels, pendingChannels,
claimsByUri,
doFetchUserMemberships,
} = props; } = props;
const [rewardData, setRewardData] = React.useState(); const [rewardData, setRewardData] = React.useState();
const hasYoutubeChannels = youtubeChannels && Boolean(youtubeChannels.length); const hasYoutubeChannels = youtubeChannels && Boolean(youtubeChannels.length);
const shouldFetchUserMemberships = true;
useGetUserMemberships(shouldFetchUserMemberships, channelUrls, claimsByUri, doFetchUserMemberships);
const { push } = useHistory(); const { push } = useHistory();
useEffect(() => { useEffect(() => {
@ -51,6 +59,7 @@ export default function ChannelsPage(props: Props) {
{channelUrls && Boolean(channelUrls.length) && ( {channelUrls && Boolean(channelUrls.length) && (
<ClaimList <ClaimList
showMemberBadge
header={<h1 className="section__title">{__('Your channels')}</h1>} header={<h1 className="section__title">{__('Your channels')}</h1>}
headerAltControls={ headerAltControls={
<Button <Button

View file

@ -5,7 +5,7 @@ import { makeSelectClaimForUri } from 'redux/selectors/claims';
import * as SETTINGS from 'constants/settings'; import * as SETTINGS from 'constants/settings';
import { doFetchActiveLivestreams } from 'redux/actions/livestream'; import { doFetchActiveLivestreams } from 'redux/actions/livestream';
import { selectActiveLivestreams } from 'redux/selectors/livestream'; import { selectActiveLivestreams } from 'redux/selectors/livestream';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { doToggleTagFollowDesktop } from 'redux/actions/tags'; import { doToggleTagFollowDesktop } from 'redux/actions/tags';
import { selectClientSetting, selectLanguage } from 'redux/selectors/settings'; import { selectClientSetting, selectLanguage } from 'redux/selectors/settings';
@ -20,9 +20,9 @@ const select = (state, props) => {
followedTags: selectFollowedTags(state), followedTags: selectFollowedTags(state),
repostedUri: repostedUri, repostedUri: repostedUri,
repostedClaim: repostedUri ? makeSelectClaimForUri(repostedUri)(state) : null, repostedClaim: repostedUri ? makeSelectClaimForUri(repostedUri)(state) : null,
isAuthenticated: selectUserVerifiedEmail(state),
tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT), tileLayout: selectClientSetting(state, SETTINGS.TILE_LAYOUT),
activeLivestreams: selectActiveLivestreams(state), activeLivestreams: selectActiveLivestreams(state),
userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
languageSetting: selectLanguage(state), languageSetting: selectLanguage(state),
searchInLanguage: selectClientSetting(state, SETTINGS.SEARCH_IN_LANGUAGE), searchInLanguage: selectClientSetting(state, SETTINGS.SEARCH_IN_LANGUAGE),
}; };

View file

@ -28,10 +28,10 @@ type Props = {
searchInLanguage: boolean, searchInLanguage: boolean,
doToggleTagFollowDesktop: (string) => void, doToggleTagFollowDesktop: (string) => void,
doResolveUri: (string) => void, doResolveUri: (string) => void,
isAuthenticated: boolean,
tileLayout: boolean, tileLayout: boolean,
activeLivestreams: ?LivestreamInfo, activeLivestreams: ?LivestreamInfo,
doFetchActiveLivestreams: (orderBy: ?Array<string>, lang: ?Array<string>) => void, doFetchActiveLivestreams: (orderBy: ?Array<string>, lang: ?Array<string>) => void,
userHasPremiumPlus: boolean,
}; };
function DiscoverPage(props: Props) { function DiscoverPage(props: Props) {
@ -44,11 +44,11 @@ function DiscoverPage(props: Props) {
searchInLanguage, searchInLanguage,
doToggleTagFollowDesktop, doToggleTagFollowDesktop,
doResolveUri, doResolveUri,
isAuthenticated,
tileLayout, tileLayout,
activeLivestreams, activeLivestreams,
doFetchActiveLivestreams, doFetchActiveLivestreams,
dynamicRouteProps, dynamicRouteProps,
userHasPremiumPlus,
} = props; } = props;
const buttonRef = useRef(); const buttonRef = useRef();
@ -191,7 +191,7 @@ function DiscoverPage(props: Props) {
hiddenNsfwMessage={<HiddenNsfw type="page" />} hiddenNsfwMessage={<HiddenNsfw type="page" />}
repostedClaimId={repostedClaim ? repostedClaim.claim_id : null} repostedClaimId={repostedClaim ? repostedClaim.claim_id : null}
injectedItem={ injectedItem={
SHOW_ADS && !isAuthenticated && !isWildWest && { node: <Ads small type="video" tileLayout={tileLayout} /> } SHOW_ADS && !userHasPremiumPlus && !isWildWest && { node: <Ads small type="video" tileLayout={tileLayout} /> }
} }
// Assume wild west page if no dynamicRouteProps // Assume wild west page if no dynamicRouteProps
// Not a very good solution, but just doing it for now // Not a very good solution, but just doing it for now

View file

@ -3,7 +3,7 @@ import * as SETTINGS from 'constants/settings';
import { doFetchActiveLivestreams } from 'redux/actions/livestream'; import { doFetchActiveLivestreams } from 'redux/actions/livestream';
import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream'; import { selectActiveLivestreams, selectFetchingActiveLivestreams } from 'redux/selectors/livestream';
import { selectFollowedTags } from 'redux/selectors/tags'; import { selectFollowedTags } from 'redux/selectors/tags';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectUserVerifiedEmail, selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import { selectSubscriptions } from 'redux/selectors/subscriptions'; import { selectSubscriptions } from 'redux/selectors/subscriptions';
import { selectShowMatureContent, selectHomepageData, selectClientSetting } from 'redux/selectors/settings'; import { selectShowMatureContent, selectHomepageData, selectClientSetting } from 'redux/selectors/settings';
@ -18,6 +18,7 @@ const select = (state) => ({
activeLivestreams: selectActiveLivestreams(state), activeLivestreams: selectActiveLivestreams(state),
fetchingActiveLivestreams: selectFetchingActiveLivestreams(state), fetchingActiveLivestreams: selectFetchingActiveLivestreams(state),
hideScheduledLivestreams: selectClientSetting(state, SETTINGS.HIDE_SCHEDULED_LIVESTREAMS), hideScheduledLivestreams: selectClientSetting(state, SETTINGS.HIDE_SCHEDULED_LIVESTREAMS),
userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}); });
const perform = (dispatch) => ({ const perform = (dispatch) => ({

View file

@ -31,6 +31,7 @@ type Props = {
doFetchActiveLivestreams: () => void, doFetchActiveLivestreams: () => void,
fetchingActiveLivestreams: boolean, fetchingActiveLivestreams: boolean,
hideScheduledLivestreams: boolean, hideScheduledLivestreams: boolean,
userHasPremiumPlus: boolean,
}; };
function HomePage(props: Props) { function HomePage(props: Props) {
@ -44,6 +45,7 @@ function HomePage(props: Props) {
doFetchActiveLivestreams, doFetchActiveLivestreams,
fetchingActiveLivestreams, fetchingActiveLivestreams,
hideScheduledLivestreams, hideScheduledLivestreams,
userHasPremiumPlus,
} = props; } = props;
const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0; const showPersonalizedChannels = (authenticated || !IS_WEB) && subscribedChannels && subscribedChannels.length > 0;
const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0; const showPersonalizedTags = (authenticated || !IS_WEB) && followedTags && followedTags.length > 0;
@ -100,7 +102,9 @@ function HomePage(props: Props) {
prefixUris={getLivestreamUris(activeLivestreams, options.channelIds)} prefixUris={getLivestreamUris(activeLivestreams, options.channelIds)}
pinUrls={pinUrls} pinUrls={pinUrls}
injectedItem={ injectedItem={
index === 0 && SHOW_ADS && !authenticated && { node: <Ads small type="video" tileLayout />, replace: true } index === 0 &&
SHOW_ADS &&
!userHasPremiumPlus && { node: <Ads small type="video" tileLayout />, replace: true }
} }
/> />
); );

View file

@ -1,8 +1,9 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectHasChannels, selectFetchingMyChannels } from 'redux/selectors/claims'; import { selectHasChannels, selectFetchingMyChannels } from 'redux/selectors/claims';
import { doClearPublish } from 'redux/actions/publish'; import { doClearPublish } from 'redux/actions/publish';
import { selectActiveChannelClaim } from 'redux/selectors/app'; import { selectActiveChannelClaim, selectActiveChannelStakedLevel } from 'redux/selectors/app';
import { doFetchNoSourceClaims } from 'redux/actions/livestream'; import { doFetchNoSourceClaims } from 'redux/actions/livestream';
import { selectUser, selectOdyseeMembershipName } from 'redux/selectors/user';
import { import {
makeSelectPendingLivestreamsForChannelId, makeSelectPendingLivestreamsForChannelId,
makeSelectLivestreamsForChannelId, makeSelectLivestreamsForChannelId,
@ -23,6 +24,9 @@ const select = (state) => {
myLivestreamClaims: makeSelectLivestreamsForChannelId(channelId)(state), myLivestreamClaims: makeSelectLivestreamsForChannelId(channelId)(state),
pendingClaims: makeSelectPendingLivestreamsForChannelId(channelId)(state), pendingClaims: makeSelectPendingLivestreamsForChannelId(channelId)(state),
fetchingLivestreams: makeSelectIsFetchingLivestreams(channelId)(state), fetchingLivestreams: makeSelectIsFetchingLivestreams(channelId)(state),
user: selectUser(state),
odyseeMembership: selectOdyseeMembershipName(state),
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
}; };
}; };
const perform = (dispatch) => ({ const perform = (dispatch) => ({

View file

@ -17,6 +17,7 @@ import Card from 'component/common/card';
import ClaimList from 'component/claimList'; import ClaimList from 'component/claimList';
import usePersistedState from 'effects/use-persisted-state'; import usePersistedState from 'effects/use-persisted-state';
import { LIVESTREAM_RTMP_URL } from 'constants/livestream'; import { LIVESTREAM_RTMP_URL } from 'constants/livestream';
import { ENABLE_NO_SOURCE_CLAIMS, CHANNEL_STAKED_LEVEL_LIVESTREAM } from '../../../config';
type Props = { type Props = {
hasChannels: boolean, hasChannels: boolean,
@ -29,6 +30,9 @@ type Props = {
fetchingLivestreams: boolean, fetchingLivestreams: boolean,
channelId: ?string, channelId: ?string,
channelName: ?string, channelName: ?string,
user: ?User,
activeChannelStakedLevel: number,
odyseeMembership: string,
}; };
export default function LivestreamSetupPage(props: Props) { export default function LivestreamSetupPage(props: Props) {
@ -44,6 +48,9 @@ export default function LivestreamSetupPage(props: Props) {
fetchingLivestreams, fetchingLivestreams,
channelId, channelId,
channelName, channelName,
user,
odyseeMembership,
activeChannelStakedLevel,
} = props; } = props;
const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined }); const [sigData, setSigData] = React.useState({ signature: undefined, signing_ts: undefined });
@ -51,6 +58,22 @@ export default function LivestreamSetupPage(props: Props) {
const hasLivestreamClaims = Boolean(myLivestreamClaims.length || pendingClaims.length); const hasLivestreamClaims = Boolean(myLivestreamClaims.length || pendingClaims.length);
const hasEnoughLBCToStream = activeChannelStakedLevel >= CHANNEL_STAKED_LEVEL_LIVESTREAM;
const { odysee_live_disabled: liveDisabled, odysee_live_enabled: liveEnabled } = user || {};
const livestreamEnabled = Boolean(
ENABLE_NO_SOURCE_CLAIMS && user && !liveDisabled && (liveEnabled || odyseeMembership || hasEnoughLBCToStream)
);
let reasonAllowedToStream = '';
if (odyseeMembership) {
reasonAllowedToStream = 'you purchased Odysee Premium';
} else if (liveEnabled) {
reasonAllowedToStream = 'your livestreaming was turned on manually';
} else if (hasEnoughLBCToStream) {
reasonAllowedToStream = 'you have enough staked LBC';
}
function createStreamKey() { function createStreamKey() {
if (!channelId || !channelName || !sigData.signature || !sigData.signing_ts) return null; if (!channelId || !channelName || !sigData.signature || !sigData.signing_ts) return null;
return `${channelId}?d=${toHex(channelName)}&s=${sigData.signature}&t=${sigData.signing_ts}`; return `${channelId}?d=${toHex(channelName)}&s=${sigData.signature}&t=${sigData.signing_ts}`;
@ -169,201 +192,231 @@ export default function LivestreamSetupPage(props: Props) {
return ( return (
<Page> <Page>
{fetchingChannels && ( {/* no livestreaming privs because no premium membership */}
<div className="main--empty"> {!livestreamEnabled && !odyseeMembership && (
<Spinner delayed /> <div>
<h2 className={''}>Join Odysee Premium to be able to livestream</h2>
<Button
button="primary"
label={__('Join Odysee Premium')}
icon={ICONS.FINANCE}
navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}`}
className="membership_button"
/>
</div> </div>
)} )}
{!fetchingChannels && !hasChannels && ( {/* show livestreaming frontend */}
<Yrbl {livestreamEnabled && (
type="happy" <div className="card-stack">
title={__("You haven't created a channel yet, let's fix that!")} {/* getting channel data */}
actions={ {fetchingChannels && (
<div className="section__actions"> <div className="main--empty">
<Button button="primary" navigate={`/$/${PAGES.CHANNEL_NEW}`} label={__('Create A Channel')} /> <Spinner delayed />
</div> </div>
} )}
/>
)}
{!fetchingChannels && (
<>
<div className="section__actions--between">
<ChannelSelector hideAnon />
</div>
</>
)}
{fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && ( {/* no channels yet */}
<div className="main--empty"> {!fetchingChannels && !hasChannels && (
<Spinner delayed /> <Yrbl
</div> type="happy"
)} title={__("You haven't created a channel yet, let's fix that!")}
<div className="card-stack"> actions={
{!fetchingChannels && channelId && ( <div className="section__actions">
<> <Button button="primary" navigate={`/$/${PAGES.CHANNEL_NEW}`} label={__('Create A Channel')} />
<Card </div>
titleActions={
<Button button="close" icon={showHelp ? ICONS.UP : ICONS.DOWN} onClick={() => setShowHelp(!showHelp)} />
} }
title={__('Go Live on Odysee')}
subtitle={<>{__(`You're invited to try out our new livestreaming service while in beta!`)} </>}
actions={showHelp && helpText}
/> />
{streamKey && totalLivestreamClaims.length > 0 && ( )}
<Card
className="section"
title={__('Your stream key')}
actions={
<>
<CopyableText
primaryButton
name="stream-server"
label={__('Stream server')}
copyable={LIVESTREAM_RTMP_URL}
snackMessage={__('Copied stream server URL.')}
/>
<CopyableText
primaryButton
enableInputMask
name="livestream-key"
label={__('Stream key (can be reused)')}
copyable={streamKey}
snackMessage={__('Copied stream key.')}
/>
</>
}
/>
)}
{totalLivestreamClaims.length > 0 ? ( {/* channel selector */}
<> {!fetchingChannels && (
{Boolean(pendingClaims.length) && ( <>
<div className="section"> <div className="section__actions--between">
<ClaimList <ChannelSelector hideAnon />
header={__('Your pending livestreams uploads')} </div>
uris={pendingClaims.map((claim) => claim.permanent_url)} </>
/> )}
</div>
)} {/* getting livestreams */}
{Boolean(myLivestreamClaims.length) && ( {fetchingLivestreams && !fetchingChannels && !hasLivestreamClaims && (
<> <div className="main--empty">
{Boolean(upcomingStreams.length) && ( <Spinner delayed />
<div className="section"> </div>
<ClaimList )}
header={<ListHeader title={__('Your Scheduled Livestreams')} />}
uris={upcomingStreams.map((claim) => claim.permanent_url)} {!fetchingChannels && channelId && (
/> <>
</div> <Card
)} titleActions={
<Button
button="close"
icon={showHelp ? ICONS.UP : ICONS.DOWN}
onClick={() => setShowHelp(!showHelp)}
/>
}
title={__('Go Live on Odysee')}
subtitle={
<>{__(`Congratulations, you have access to livestreaming because ${reasonAllowedToStream}!`)} </>
}
actions={showHelp && helpText}
/>
{streamKey && totalLivestreamClaims.length > 0 && (
<Card
className="section"
title={__('Your stream key')}
actions={
<>
<CopyableText
primaryButton
name="stream-server"
label={__('Stream server')}
copyable={LIVESTREAM_RTMP_URL}
snackMessage={__('Copied stream server URL.')}
/>
<CopyableText
primaryButton
enableInputMask
name="livestream-key"
label={__('Stream key (can be reused)')}
copyable={streamKey}
snackMessage={__('Copied stream key.')}
/>
</>
}
/>
)}
{totalLivestreamClaims.length > 0 ? (
<>
{Boolean(pendingClaims.length) && (
<div className="section"> <div className="section">
<ClaimList <ClaimList
header={ header={__('Your pending livestreams uploads')}
<ListHeader title={__('Your Past Livestreams')} hideBtn={Boolean(upcomingStreams.length)} /> uris={pendingClaims.map((claim) => claim.permanent_url)}
}
empty={
<I18nMessage
tokens={{
check_again: (
<Button
button="link"
onClick={() => fetchNoSourceClaims(channelId)}
label={__('Check again')}
/>
),
}}
>
Nothing here yet. %check_again%
</I18nMessage>
}
uris={pastStreams.map((claim) => claim.permanent_url)}
/> />
</div> </div>
</> )}
)} {Boolean(myLivestreamClaims.length) && (
</> <>
) : ( {Boolean(upcomingStreams.length) && (
<Yrbl <div className="section">
className="livestream__publish-intro" <ClaimList
title={__('No livestream publishes found')} header={<ListHeader title={__('Your Scheduled Livestreams')} />}
subtitle={__( uris={upcomingStreams.map((claim) => claim.permanent_url)}
'You need to upload your livestream details before you can go live. Please note: Replays must be published manually after your stream via the Update button on the livestream.' />
)} </div>
actions={ )}
<div className="section__actions"> <div className="section">
<Button <ClaimList
button="primary" header={
onClick={() => <ListHeader title={__('Your Past Livestreams')} hideBtn={Boolean(upcomingStreams.length)} />
doNewLivestream(`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.LIVESTREAM.toLowerCase()}`) }
} empty={
label={__('Create A Livestream')} <I18nMessage
/> tokens={{
<Button check_again: (
button="alt" <Button
onClick={() => { button="link"
fetchNoSourceClaims(channelId); onClick={() => fetchNoSourceClaims(channelId)}
}} label={__('Check again')}
label={__('Check again...')} />
/> ),
</div> }}
} >
/> Nothing here yet. %check_again%
)} </I18nMessage>
}
{/* Debug Stuff */} uris={pastStreams.map((claim) => claim.permanent_url)}
{streamKey && false && activeChannelClaim && ( />
<div style={{ marginTop: 'var(--spacing-l)' }}> </div>
<h3>Debug Info</h3> </>
)}
{/* Channel ID */} </>
<FormField ) : (
name={'channelId'} <Yrbl
label={'Channel ID'} className="livestream__publish-intro"
type={'text'} title={__('No livestream publishes found')}
defaultValue={activeChannelClaim.claim_id} subtitle={__(
readOnly 'You need to upload your livestream details before you can go live. Please note: Replays must be published manually after your stream via the Update button on the livestream.'
)}
actions={
<div className="section__actions">
<Button
button="primary"
onClick={() =>
doNewLivestream(`/$/${PAGES.UPLOAD}?type=${PUBLISH_MODES.LIVESTREAM.toLowerCase()}`)
}
label={__('Create A Livestream')}
/>
<Button
button="alt"
onClick={() => {
fetchNoSourceClaims(channelId);
}}
label={__('Check again...')}
/>
</div>
}
/> />
)}
{/* Signature */} {/* Debug Stuff */}
<FormField {streamKey && false && activeChannelClaim && (
name={'signature'} <div style={{ marginTop: 'var(--spacing-l)' }}>
label={'Signature'} <h3>Debug Info</h3>
type={'text'}
defaultValue={sigData.signature}
readOnly
/>
{/* Signature TS */} {/* Channel ID */}
<FormField <FormField
name={'signaturets'} name={'channelId'}
label={'Signature Timestamp'} label={'Channel ID'}
type={'text'} type={'text'}
defaultValue={sigData.signing_ts} defaultValue={activeChannelClaim.claim_id}
readOnly readOnly
/> />
{/* Hex Data */} {/* Signature */}
<FormField <FormField
name={'datahex'} name={'signature'}
label={'Hex Data'} label={'Signature'}
type={'text'} type={'text'}
defaultValue={toHex(activeChannelClaim.name)} defaultValue={sigData.signature}
readOnly readOnly
/> />
{/* Channel Public Key */} {/* Signature TS */}
<FormField <FormField
name={'channelpublickey'} name={'signaturets'}
label={'Public Key'} label={'Signature Timestamp'}
type={'text'} type={'text'}
defaultValue={activeChannelClaim.value.public_key} defaultValue={sigData.signing_ts}
readOnly readOnly
/> />
</div>
)} {/* Hex Data */}
</> <FormField
)} name={'datahex'}
</div> label={'Hex Data'}
type={'text'}
defaultValue={toHex(activeChannelClaim.name)}
readOnly
/>
{/* Channel Public Key */}
<FormField
name={'channelpublickey'}
label={'Public Key'}
type={'text'}
defaultValue={activeChannelClaim.value.public_key}
readOnly
/>
</div>
)}
</>
)}
</div>
)}
</Page> </Page>
); );
} }

View file

@ -0,0 +1,27 @@
import { connect } from 'react-redux';
import { doOpenModal } from 'redux/actions/app';
import OdyseeMembership from './view';
import { selectActiveChannelClaim, selectIncognito } from 'redux/selectors/app';
import { selectMyChannelClaims, selectClaimsByUri } from 'redux/selectors/claims';
import { doFetchUserMemberships, doCheckUserOdyseeMemberships } from 'redux/actions/user';
import { selectUser } from 'redux/selectors/user';
const select = (state) => {
const activeChannelClaim = selectActiveChannelClaim(state);
return {
activeChannelClaim,
channels: selectMyChannelClaims(state),
claimsByUri: selectClaimsByUri(state),
incognito: selectIncognito(state),
user: selectUser(state),
};
};
const perform = (dispatch) => ({
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
fetchUserMemberships: (claimIds) => dispatch(doFetchUserMemberships(claimIds)),
updateUserOdyseeMembershipStatus: (user) => dispatch(doCheckUserOdyseeMemberships(user)),
});
export default connect(select, perform)(OdyseeMembership);

View file

@ -0,0 +1,646 @@
/* eslint-disable no-console */
// @flow
import React from 'react';
import moment from 'moment';
import Page from 'component/page';
import Spinner from 'component/spinner';
import { Lbryio } from 'lbryinc';
import { getStripeEnvironment } from 'util/stripe';
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import * as MODALS from 'constants/modal_types';
import Card from 'component/common/card';
import MembershipSplash from 'component/membershipSplash';
import Button from 'component/button';
import ChannelSelector from 'component/channelSelector';
import PremiumBadge from 'component/common/premium-badge';
import useGetUserMemberships from 'effects/use-get-user-memberships';
import usePersistedState from 'effects/use-persisted-state';
let stripeEnvironment = getStripeEnvironment();
const isDev = process.env.NODE_ENV !== 'production';
// odysee channel information since the memberships are only for Odysee
const odyseeChannelId = '80d2590ad04e36fb1d077a9b9e3a8bba76defdf8';
const odyseeChannelName = '@odysee';
type Props = {
history: { action: string, push: (string) => void, replace: (string) => void },
location: { search: string, pathname: string },
totalBalance: ?number,
openModal: (string, {}) => void,
activeChannelClaim: ?ChannelClaim,
channels: ?Array<ChannelClaim>,
claimsByUri: { [string]: any },
fetchUserMemberships: (claimIdCsv: string) => void,
incognito: boolean,
updateUserOdyseeMembershipStatus: () => void,
user: ?User,
};
const OdyseeMembershipPage = (props: Props) => {
const {
openModal,
activeChannelClaim,
channels,
claimsByUri,
fetchUserMemberships,
updateUserOdyseeMembershipStatus,
incognito,
user,
} = props;
const shouldUseEuro = localStorage.getItem('gdprRequired');
let currencyToUse;
if (shouldUseEuro === 'true') {
currencyToUse = 'eur';
} else {
currencyToUse = 'usd';
}
const userChannelName = activeChannelClaim ? activeChannelClaim.name : '';
const userChannelClaimId = activeChannelClaim && activeChannelClaim.claim_id;
const [cardSaved, setCardSaved] = React.useState();
const [membershipOptions, setMembershipOptions] = React.useState();
const [userMemberships, setUserMemberships] = React.useState();
const [canceledMemberships, setCanceledMemberships] = React.useState();
const [activeMemberships, setActiveMemberships] = React.useState();
const [purchasedMemberships, setPurchasedMemberships] = React.useState([]);
const [hasShownModal, setHasShownModal] = React.useState(false);
const [shouldFetchUserMemberships, setFetchUserMemberships] = React.useState(true);
const [showHelp, setShowHelp] = usePersistedState('premium-help-seen', true);
const hasMembership = activeMemberships && activeMemberships.length > 0;
const channelUrls = channels && channels.map((channel) => channel.permanent_url);
// check if membership data for user is already fetched, if it's needed then fetch it
useGetUserMemberships(shouldFetchUserMemberships, channelUrls, claimsByUri, (value) => {
fetchUserMemberships(value);
setFetchUserMemberships(false);
});
async function populateMembershipData() {
try {
// show the memberships the user is subscribed to
const response = await Lbryio.call(
'membership',
'mine',
{
environment: stripeEnvironment,
},
'post'
);
let activeMemberships = [];
let canceledMemberships = [];
let purchasedMemberships = [];
for (const membership of response) {
// if it's autorenewing it's considered 'active'
const isActive = membership.Membership.auto_renew;
if (isActive) {
activeMemberships.push(membership);
} else {
canceledMemberships.push(membership);
}
purchasedMemberships.push(membership.Membership.membership_id);
}
// hide the other membership options if there's already a purchased membership
if (activeMemberships.length > 0) {
setMembershipOptions(false);
}
setActiveMemberships(activeMemberships);
setCanceledMemberships(canceledMemberships);
setPurchasedMemberships(purchasedMemberships);
// update the state to show the badge
fetchUserMemberships(userChannelClaimId || '');
setUserMemberships(response);
} catch (err) {
console.log(err);
}
setFetchUserMemberships(false);
}
React.useEffect(() => {
if (!shouldFetchUserMemberships) setFetchUserMemberships(true);
}, [shouldFetchUserMemberships]);
React.useEffect(function () {
(async function () {
try {
// check if there is a payment method
const response = await Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
);
// hardcoded to first card
const hasAPaymentCard = Boolean(response && response.PaymentMethods && response.PaymentMethods[0]);
setCardSaved(hasAPaymentCard);
} catch (err) {
const customerDoesntExistError = 'user as customer is not setup yet';
if (err.message === customerDoesntExistError) {
setCardSaved(false);
} else {
console.log(err);
}
}
try {
// check the available membership for odysee.com
const response = await Lbryio.call(
'membership',
'list',
{
environment: stripeEnvironment,
channel_id: odyseeChannelId,
channel_name: odyseeChannelName,
},
'post'
);
// hide other options if there's already a membership
if (activeMemberships && activeMemberships.length > 0) {
setMembershipOptions(false);
} else {
setMembershipOptions(response);
}
} catch (err) {
console.log(err);
}
populateMembershipData();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stillWaitingFromBackend =
purchasedMemberships === undefined ||
cardSaved === undefined ||
membershipOptions === undefined ||
userMemberships === undefined;
const formatDate = function (date) {
return moment(new Date(date)).format('MMMM DD YYYY');
};
const deleteData = async function () {
await Lbryio.call('membership', 'clear', {}, 'post');
// $FlowFixMe
location.reload();
};
// dont pass channel name and id when calling purchase
const noChannelsOrIncognitoMode = incognito || !channels;
// TODO: can clean this up, some repeating text
function buildPurchaseString(price, interval, plan) {
let featureString = '';
// generate different strings depending on other conditions
if (plan === 'Premium' && !noChannelsOrIncognitoMode) {
featureString =
'Your badge will be shown for your ' +
userChannelName +
' channel in all areas of the app, and can be added to two additional channels in the future for free. ';
} else if (plan === 'Premium+' && !noChannelsOrIncognitoMode) {
featureString =
'The no ads feature applies site-wide for all channels and your badge will be shown for your ' +
userChannelName +
' channel in all areas of the app, and can be added to two additional channels in the future for free. ';
} else if (plan === 'Premium' && !channels) {
featureString =
'You currently have no channels. To show your badge on a channel, please create a channel first. ' +
'If you register a channel later you will be able to show a badge for up to three channels.';
} else if (plan === 'Premium+' && !channels) {
featureString =
'The no ads feature applies site-wide. You currently have no channels. To show your badge on a channel, please create a channel first. ' +
'If you register a channel later you will be able to show a badge for up to three channels.';
} else if (plan === 'Premium' && incognito) {
featureString =
'You currently have no channel selected and will not have a badge be visible, if you want to show a badge you can select a channel now, ' +
'or you can show a badge for up to three channels in the future for free.';
} else if (plan === 'Premium+' && incognito) {
featureString =
'The no ads feature applies site-wide. You currently have no channel selected and will not have a badge be visible, ' +
'if you want to show a badge you can select a channel now, or you can show a badge for up to three channels in the future for free.';
}
let purchaseString =
`You are purchasing a ${interval}ly membership, that is active immediately ` +
`and will renew ${interval}ly at a price of ${currencyToUse.toUpperCase()} ${
currencyToUse === 'usd' ? '$' : '€'
}${price / 100}. ` +
featureString +
'You can cancel Premium at any time (no refunds) and you can also close this window and choose a different membership option.';
return __(purchaseString);
}
const purchaseMembership = function (e, membershipOption, price) {
e.preventDefault();
e.stopPropagation();
const planName = membershipOption.Membership.name;
const membershipId = e.currentTarget.getAttribute('membership-id');
const priceId = e.currentTarget.getAttribute('price-id');
const purchaseString = buildPurchaseString(price.unit_amount, price.recurring.interval, planName);
openModal(MODALS.CONFIRM_ODYSEE_MEMBERSHIP, {
membershipId,
userChannelClaimId: noChannelsOrIncognitoMode ? undefined : userChannelClaimId,
userChannelName: noChannelsOrIncognitoMode ? undefined : userChannelName,
priceId,
purchaseString,
plan: planName,
populateMembershipData,
setMembershipOptions,
updateUserOdyseeMembershipStatus,
user,
});
};
const cancelMembership = async function (e, membership) {
const membershipId = e.currentTarget.getAttribute('membership-id');
const cancellationString =
'You are cancelling your Odysee Premium. You will still have access to all the paid ' +
'features until the point of the expiration of your current membership, at which point you will not be charged ' +
'again and your membership will no longer be active. At this time, there is no way to subscribe to another membership if you cancel and there are no refunds.';
openModal(MODALS.CONFIRM_ODYSEE_MEMBERSHIP, {
membershipId,
hasMembership,
purchaseString: cancellationString,
populateMembershipData,
});
};
function convertPriceToString(price) {
const interval = price.recurring.interval;
if (interval === 'year') {
return 'Yearly';
} else if (interval === 'month') {
return 'Monthly';
}
}
function capitalizeWord(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
function buildCurrencyDisplay(priceObject) {
let currencySymbol;
if (priceObject.currency === 'eur') {
currencySymbol = '€';
} else if (priceObject.currency === 'usd') {
currencySymbol = '$';
}
const currency = priceObject.currency.toUpperCase();
return currency + ' ' + currencySymbol;
}
const urlSearchParams = new URLSearchParams(window.location.search);
const params = Object.fromEntries(urlSearchParams.entries());
const { interval, plan } = params;
const planValue = params.plan;
// description to be shown under plan name
function getPlanDescription(plan) {
if (plan === 'Premium') {
return 'Badge on profile, exclusive and early access to features';
// if there's more plans added this needs to be expanded
} else {
return 'All Premium features, and no ads';
}
}
// add a bit of a delay otherwise it's a bit jarring
const timeoutValue = 300;
// if user already selected plan, wait a bit (so it's not jarring) and open modal
React.useEffect(() => {
if (!stillWaitingFromBackend && planValue && cardSaved) {
setTimeout(function () {
// clear query params
window.history.replaceState(null, null, window.location.pathname);
setHasShownModal(true);
// open confirm purchase
// $FlowFixMe
document.querySelector('[plan="' + plan + '"][interval="' + interval + '"]').click();
}, timeoutValue);
}
}, [stillWaitingFromBackend, planValue, cardSaved]);
const helpText = (
<div className="section__subtitle">
<p>
{__(
`First of all, thank you for considering or purchasing a membership, it means a ton to us! A few important details to know: `
)}
</p>
<p>
<ul>
<li>
{__(
`Early access and exclusive features include: livestreaming and the ability to post odysee hyperlinks and images in comments + blogs. More to come later.`
)}
<li>
{__(
`The yearly Premium+ membership has a discount compared to monthly, and Premium is only available yearly.`
)}
</li>
<li>{__(`These are limited time rates, so get in early!`)}</li>
</li>
<li>
{__(
`There may be higher tiers available in the future for creators and anyone else who wants to support us.`
)}
</li>
<li>
{__(`Badges will be displayed on a single channel to start, with an option to add on two more later on.`)}
</li>
<li>
{__(`Cannot upgrade or downgrade a membership at this time. Refunds are not available. Choose wisely.`)}
</li>
</ul>
</p>
</div>
);
return (
<>
<Page className="premium-wrapper">
{/** splash frontend **/}
{!stillWaitingFromBackend && purchasedMemberships.length === 0 && !planValue && !hasShownModal ? (
<MembershipSplash pageLocation={'confirmPage'} currencyToUse={currencyToUse} />
) : (
/** odysee membership page **/
<div className={'card-stack'}>
{!stillWaitingFromBackend && cardSaved !== false && (
<>
<h1 style={{ fontSize: '23px' }}>{__('Odysee Premium')}</h1>
{/* let user switch channel */}
<div style={{ marginTop: '10px' }}>
<ChannelSelector uri={activeChannelClaim && activeChannelClaim.permanent_url} />
{/* explainer help text */}
<Card
titleActions={
<Button
button="close"
icon={showHelp ? ICONS.UP : ICONS.DOWN}
onClick={() => setShowHelp(!showHelp)}
/>
}
title={__('Get More Information')}
subtitle={<>{__(`Expand to learn more about how Odysee Premium works`)} </>}
actions={showHelp && helpText}
className={'premium-explanation-text'}
/>
</div>
</>
)}
{/** available memberships **/}
{/* if they have a card and don't have a membership yet */}
{!stillWaitingFromBackend && membershipOptions && purchasedMemberships.length < 1 && cardSaved !== false && (
<>
<div className="card__title-section">
<h2 className="card__title">{__('Available Memberships')}</h2>
</div>
<Card>
{membershipOptions.map((membershipOption, i) => (
<>
<div key={i}>
{purchasedMemberships && !purchasedMemberships.includes(membershipOption.Membership.id) && (
<>
<div className="premium-option">
<h4 className="membership_title">
{membershipOption.Membership.name}
<PremiumBadge membership={membershipOption.Membership.name} />
</h4>
{/* plan description */}
<h4 className="membership_subtitle">
{getPlanDescription(membershipOption.Membership.name)}
</h4>
<>
{membershipOption.Prices.map((price) => (
<>
{/* dont show a monthly Premium membership option */}
{!(
price.recurring.interval === 'month' &&
membershipOption.Membership.name === 'Premium'
) && (
<>
{price.currency === currencyToUse && (
<div>
<h4 className="membership_info">
<b>Interval:</b> {convertPriceToString(price)}
</h4>
<h4 className="membership_info">
<b>Price:</b> {buildCurrencyDisplay(price)}
{price.unit_amount / 100}/{capitalizeWord(price.recurring.interval)}
</h4>
<Button
button="primary"
onClick={(e) => purchaseMembership(e, membershipOption, price)}
membership-id={membershipOption.Membership.id}
membership-subscription-period={membershipOption.Membership.type}
price-id={price.id}
className="membership_button"
label={__('Join via ' + price.recurring.interval + 'ly membership')}
icon={ICONS.FINANCE}
interval={price.recurring.interval}
plan={membershipOption.Membership.name}
/>
</div>
)}
</>
)}
</>
))}
</>
</div>
</>
)}
</div>
</>
))}
</Card>
</>
)}
{!stillWaitingFromBackend && cardSaved === true && (
<>
<div className="card__title-section">
<h2 className="card__title">{__('Your Active Memberships')}</h2>
</div>
<Card>
{/** * list of active memberships from user ***/}
<div>
{/* <h1 style={{ fontSize: '19px' }}>Active Memberships</h1> */}
{!stillWaitingFromBackend && activeMemberships && activeMemberships.length === 0 && (
<>
<h4>{__('You currently have no active memberships')}</h4>
</>
)}
{/** active memberships **/}
{!stillWaitingFromBackend &&
activeMemberships &&
activeMemberships.map((membership) => (
<>
<div className="premium-option">
{/* membership name */}
<h4 className="membership_title">
{membership.MembershipDetails.name}
<PremiumBadge membership={membership.MembershipDetails.name} />
</h4>
{/* description section */}
<h4 className="membership_subtitle">
{getPlanDescription(membership.MembershipDetails.name)}
</h4>
<h4 className="membership_info">
<b>{__('Registered On:')}</b> {formatDate(membership.Membership.created_at)}
</h4>
<h4 className="membership_info">
<b>{__('Auto-Renews On')}:</b>{' '}
{formatDate(membership.Subscription.current_period_end * 1000)}
</h4>
{!stillWaitingFromBackend && membership.type === 'yearly' && (
<>
<h4 className="membership_info">
<b>{__('Membership Period Options:')}</b> {__('Yearly')}
</h4>
<h4 className="membership_info">
${(membership.cost_usd * 12) / 100} {__('USD For A One Year Membership')} ($
{membership.cost_usd / 100} {__('Per Month')})
</h4>
</>
)}
<Button
button="alt"
membership-id={membership.Membership.membership_id}
onClick={(e) => cancelMembership(e, membership)}
className="cancel-membership-button"
label={__('Cancel membership')}
icon={ICONS.FINANCE}
/>
</div>
</>
))}
</div>
</Card>
<>
{/** canceled memberships **/}
<div className="card__title-section">
<h2 className="card__title">{__('Canceled Memberships')}</h2>
</div>
<Card>
{canceledMemberships && canceledMemberships.length === 0 && (
<>
<h4>{__('You currently have no canceled memberships')}</h4>
</>
)}
{canceledMemberships &&
canceledMemberships.map((membership) => (
<>
<h4 className="membership_title">
{membership.MembershipDetails.name}
<PremiumBadge membership={membership.MembershipDetails.name} />
</h4>
<div className="premium-option">
<h4 className="membership_info">
<b>{__('Registered On:')}</b> {formatDate(membership.Membership.created_at)}
</h4>
<h4 className="membership_info">
<b>{__('Canceled On:')}</b> {formatDate(membership.Subscription.canceled_at * 1000)}
</h4>
<h4 className="membership_info">
<b>{__('Still Valid Until:')}</b> {formatDate(membership.Membership.expires)}
</h4>
</div>
</>
))}
</Card>
</>
</>
)}
{/** send user to add card if they don't have one yet */}
{!stillWaitingFromBackend && cardSaved === false && (
<div>
<br />
<h2 className={'getPaymentCard'}>
{__(
'Please save a card as a payment method so you can join Odysee Premium. After the card is added, click Back.'
)}
</h2>
<Button
button="primary"
label={__('Add A Card')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`}
className="membership_button"
/>
</div>
)}
{/** loading section **/}
{stillWaitingFromBackend && (
<div className="main--empty">
<Spinner />
</div>
)}
{/** clear membership data (only available on dev) **/}
{isDev && cardSaved && purchasedMemberships.length > 0 && (
<>
<h1 style={{ marginTop: '30px', fontSize: '20px' }}>
{__('Clear Membership Data (Only Available On Dev)')}
</h1>
<div>
<Button
button="primary"
label={__('Clear Membership Data')}
icon={ICONS.SETTINGS}
className="membership_button"
onClick={deleteData}
/>
</div>
</>
)}
</div>
)}
</Page>
</>
);
};
export default OdyseeMembershipPage;

View file

@ -22,7 +22,7 @@ import {
} from 'redux/selectors/file_info'; } from 'redux/selectors/file_info';
type Dispatch = (action: any) => any; type Dispatch = (action: any) => any;
type GetState = () => { claims: any, file: FileState, content: any }; type GetState = () => { claims: any, file: FileState, content: any, user: User };
export function doOpenFileInFolder(path: string) { export function doOpenFileInFolder(path: string) {
return () => { return () => {
shell.showItemInFolder(path); shell.showItemInFolder(path);

View file

@ -13,7 +13,7 @@ import { SEARCH_SERVER_API, SEARCH_SERVER_API_ALT } from 'config';
import { SEARCH_OPTIONS } from 'constants/search'; import { SEARCH_OPTIONS } from 'constants/search';
type Dispatch = (action: any) => any; type Dispatch = (action: any) => any;
type GetState = () => { claims: any, search: SearchState }; type GetState = () => { claims: any, search: SearchState, user: User };
type SearchOptions = { type SearchOptions = {
size?: number, size?: number,

View file

@ -3,6 +3,7 @@ import { makeSelectClaimForUri } from 'redux/selectors/claims';
import { doFetchChannelListMine } from 'redux/actions/claims'; import { doFetchChannelListMine } from 'redux/actions/claims';
import { isURIValid, normalizeURI } from 'util/lbryURI'; import { isURIValid, normalizeURI } from 'util/lbryURI';
import { batchActions } from 'util/batch-actions'; import { batchActions } from 'util/batch-actions';
import { getStripeEnvironment } from 'util/stripe';
import * as ACTIONS from 'constants/action_types'; import * as ACTIONS from 'constants/action_types';
import { doClaimRewardType, doRewardList } from 'redux/actions/rewards'; import { doClaimRewardType, doRewardList } from 'redux/actions/rewards';
@ -16,6 +17,9 @@ const AUTH_IN_PROGRESS = 'authInProgress';
export let sessionStorageAvailable = false; export let sessionStorageAvailable = false;
const CHECK_INTERVAL = 200; const CHECK_INTERVAL = 200;
const AUTH_WAIT_TIMEOUT = 10000; const AUTH_WAIT_TIMEOUT = 10000;
const stripeEnvironment = getStripeEnvironment();
const ODYSEE_CHANNEL_ID = '80d2590ad04e36fb1d077a9b9e3a8bba76defdf8';
export function doFetchInviteStatus(shouldCallRewardList = true) { export function doFetchInviteStatus(shouldCallRewardList = true) {
return (dispatch) => { return (dispatch) => {
@ -101,6 +105,59 @@ function checkAuthBusy() {
}); });
} }
/***
* Given a user, return their highest ranking Odysee membership (Premium or Premium Plus)
* @param dispatch
* @param user
* @returns {Promise<void>}
*/
export function doCheckUserOdyseeMemberships(user) {
return async (dispatch) => {
// get memberships for a given user
// TODO: in the future, can we specify this just to @odysee?
const response = await Lbryio.call(
'membership',
'mine',
{
environment: stripeEnvironment,
},
'post'
);
let savedMemberships = [];
let highestMembershipRanking;
// TODO: this will work for now, but it should be adjusted
// TODO: to check if it's active, or if it's cancelled if it's still valid past current date
// loop through all memberships and save the @odysee ones
// maybe in the future we can only hit @odysee in the API call
for (const membership of response) {
if (membership.MembershipDetails && membership.MembershipDetails.channel_name === '@odysee') {
savedMemberships.push(membership.MembershipDetails.name);
}
}
// determine highest ranking membership based on returned data
// note: this is from an odd state in the API where a user can be both premium/Premium + at the same time
// I expect this can change once upgrade/downgrade is implemented
if (savedMemberships.length > 0) {
// if premium plus is a membership, return that, otherwise it's only premium
const premiumPlusExists = savedMemberships.includes('Premium+');
if (premiumPlusExists) {
highestMembershipRanking = 'Premium+';
} else {
highestMembershipRanking = 'Premium';
}
}
dispatch({
type: ACTIONS.ADD_ODYSEE_MEMBERSHIP_DATA,
data: { user, odyseeMembershipName: highestMembershipRanking },
});
};
}
// TODO: Call doInstallNew separately so we don't have to pass appVersion and os_system params? // TODO: Call doInstallNew separately so we don't have to pass appVersion and os_system params?
export function doAuthenticate( export function doAuthenticate(
appVersion, appVersion,
@ -124,6 +181,11 @@ export function doAuthenticate(
data: { user, accessToken: token }, data: { user, accessToken: token },
}); });
// if user is an Odysee member, get the membership details
if (user.odysee_member) {
dispatch(doCheckUserOdyseeMemberships(user));
}
if (shareUsageData) { if (shareUsageData) {
dispatch(doRewardList()); dispatch(doRewardList());
@ -153,6 +215,11 @@ export function doUserFetch() {
Lbryio.getCurrentUser() Lbryio.getCurrentUser()
.then((user) => { .then((user) => {
// get user membership status
if (user.odysee_member) {
dispatch(doCheckUserOdyseeMemberships(user));
}
dispatch({ dispatch({
type: ACTIONS.USER_FETCH_SUCCESS, type: ACTIONS.USER_FETCH_SUCCESS,
data: { user }, data: { user },
@ -174,6 +241,11 @@ export function doUserCheckEmailVerified() {
return (dispatch) => { return (dispatch) => {
Lbryio.getCurrentUser().then((user) => { Lbryio.getCurrentUser().then((user) => {
if (user.has_verified_email) { if (user.has_verified_email) {
// check premium membership
if (user.odysee_member) {
dispatch(doCheckUserOdyseeMemberships(user));
}
dispatch(doRewardList()); dispatch(doRewardList());
dispatch({ dispatch({
@ -347,16 +419,19 @@ export function doUserCheckIfEmailExists(email) {
Lbryio.call('user', 'exists', { email }, 'post') Lbryio.call('user', 'exists', { email }, 'post')
.catch((error) => { .catch((error) => {
// no email
if (error.response && error.response.status === 404) { if (error.response && error.response.status === 404) {
dispatch({ dispatch({
type: ACTIONS.USER_EMAIL_NEW_DOES_NOT_EXIST, type: ACTIONS.USER_EMAIL_NEW_DOES_NOT_EXIST,
}); });
// sign in by email
} else if (error.response && error.response.status === 412) { } else if (error.response && error.response.status === 412) {
triggerEmailFlow(false); triggerEmailFlow(false);
} }
throw error; throw error;
}) })
// sign the user in
.then(success, failure); .then(success, failure);
}; };
} }
@ -784,3 +859,40 @@ export function doCheckYoutubeTransfer() {
}); });
}; };
} }
/***
* Receives a csv of channel claim ids, hits the backend and returns nicely formatted object with relevant info
* @param claimIdCsv
* @returns {(function(*): Promise<void>)|*}
*/
export function doFetchUserMemberships(claimIdCsv) {
return async (dispatch) => {
// check if users have odysee memberships (premium/premium+)
const response = await Lbryio.call('membership', 'check', {
channel_id: ODYSEE_CHANNEL_ID,
claim_ids: claimIdCsv,
});
let updatedResponse = {};
// loop through returned users
for (const user in response) {
// if array was returned for a user (indicating a membership exists), otherwise is null
if (response[user] && response[user].length) {
// get membership for user
// note: a for loop is kind of odd, indicates there may be multiple memberships?
// probably not needed depending on what we do with the frontend, should revisit
for (const membership of response[user]) {
if (membership.channel_name) {
updatedResponse[user] = membership.name;
}
}
} else {
// note the user has been fetched but is null
updatedResponse[user] = null;
}
}
dispatch({ type: ACTIONS.ADD_CLAIMIDS_MEMBERSHIP_DATA, data: { response: updatedResponse } });
};
}

View file

@ -363,6 +363,28 @@ reducers[ACTIONS.USER_PASSWORD_SET_FAILURE] = (state, action) =>
passwordSetError: action.data.error, passwordSetError: action.data.error,
}); });
reducers[ACTIONS.ADD_ODYSEE_MEMBERSHIP_DATA] = (state, action) => {
return Object.assign({}, state, {
odyseeMembershipName: action.data.odyseeMembershipName,
});
};
reducers[ACTIONS.ADD_CLAIMIDS_MEMBERSHIP_DATA] = (state, action) => {
let latestData = {};
// add additional user membership value
if (state.odyseeMembershipsPerClaimIds) {
latestData = Object.assign({}, state.odyseeMembershipsPerClaimIds, action.data.response);
} else {
// otherwise just send the current data because nothing is saved yet
latestData = action.data.response;
}
return Object.assign({}, state, {
odyseeMembershipsPerClaimIds: latestData,
});
};
export default function userReducer(state = defaultState, action) { export default function userReducer(state = defaultState, action) {
const handler = reducers[action.type]; const handler = reducers[action.type];
if (handler) return handler(state, action); if (handler) return handler(state, action);

View file

@ -9,7 +9,7 @@ import { isClaimNsfw, filterClaims, getChannelIdFromClaim, isStreamPlaceholderCl
import * as CLAIM from 'constants/claim'; import * as CLAIM from 'constants/claim';
import { INTERNAL_TAGS } from 'constants/tags'; import { INTERNAL_TAGS } from 'constants/tags';
type State = { claims: any }; type State = { claims: any, user: User };
const selectState = (state: State) => state.claims || {}; const selectState = (state: State) => state.claims || {};
@ -791,3 +791,41 @@ export const selectIsMyChannelCountOverLimit = createSelector(
return false; return false;
} }
); );
/**
* Given a uri of a channel, check if there an Odysee membership value
* @param state
* @param uri
* @returns {*}
*/
export const selectOdyseeMembershipForUri = function (state: State, uri: string) {
const claim = selectClaimForUri(state, uri);
const uploaderChannelClaimId = getChannelIdFromClaim(claim);
// looks for the uploader id
if (uploaderChannelClaimId) {
const matchingMembershipOfUser =
state.user &&
state.user.odyseeMembershipsPerClaimIds &&
state.user.odyseeMembershipsPerClaimIds[uploaderChannelClaimId];
return matchingMembershipOfUser;
}
return undefined;
};
/**
* Given a uri of a channel, check if there an Odysee membership value
* @param state
* @param channelId
* @returns {*}
*/
export const selectOdyseeMembershipForChannelId = function (state: State, channelId: string) {
// looks for the uploader id
const matchingMembershipOfUser =
state.user && state.user.odyseeMembershipsPerClaimIds && state.user.odyseeMembershipsPerClaimIds[channelId];
return matchingMembershipOfUser;
};

View file

@ -16,7 +16,7 @@ import { isClaimNsfw, getChannelFromClaim } from 'util/claim';
import { selectSubscriptionUris } from 'redux/selectors/subscriptions'; import { selectSubscriptionUris } from 'redux/selectors/subscriptions';
import { getCommentsListTitle } from 'util/comments'; import { getCommentsListTitle } from 'util/comments';
type State = { claims: any, comments: CommentsState }; type State = { claims: any, comments: CommentsState, user: User };
const selectState = (state) => state.comments || {}; const selectState = (state) => state.comments || {};

View file

@ -18,7 +18,7 @@ import { FORCE_CONTENT_TYPE_PLAYER, FORCE_CONTENT_TYPE_COMIC } from 'constants/c
const RECENT_HISTORY_AMOUNT = 10; const RECENT_HISTORY_AMOUNT = 10;
const HISTORY_ITEMS_PER_PAGE = 50; const HISTORY_ITEMS_PER_PAGE = 50;
type State = { claims: any, content: any }; type State = { claims: any, content: any, user: User };
export const selectState = (state: State) => state.content || {}; export const selectState = (state: State) => state.content || {};

View file

@ -20,7 +20,7 @@ import { selectHistory } from 'redux/selectors/content';
import { selectAllCostInfoByUri } from 'lbryinc'; import { selectAllCostInfoByUri } from 'lbryinc';
import { SIMPLE_SITE } from 'config'; import { SIMPLE_SITE } from 'config';
type State = { claims: any, search: SearchState }; type State = { claims: any, search: SearchState, user: User };
export const selectState = (state: State): SearchState => state.search; export const selectState = (state: State): SearchState => state.search;

View file

@ -104,6 +104,14 @@ export const selectYouTubeImportError = (state) => selectState(state).youtubeCha
export const selectSetReferrerPending = (state) => selectState(state).referrerSetIsPending; export const selectSetReferrerPending = (state) => selectState(state).referrerSetIsPending;
export const selectSetReferrerError = (state) => selectState(state).referrerSetError; export const selectSetReferrerError = (state) => selectState(state).referrerSetError;
export const selectOdyseeMembershipName = (state) => selectState(state).odyseeMembershipName;
export const selectOdyseeMembershipIsPremiumPlus = (state) => {
const odyseeMembershipName = selectState(state).odyseeMembershipName;
if (!odyseeMembershipName) return undefined;
return selectState(state).odyseeMembershipName === 'Premium+';
};
export const selectYouTubeImportVideosComplete = createSelector(selectState, (state) => { export const selectYouTubeImportVideosComplete = createSelector(selectState, (state) => {
const total = state.youtubeChannelImportTotal; const total = state.youtubeChannelImportTotal;
const complete = state.youtubeChannelImportComplete || 0; const complete = state.youtubeChannelImportComplete || 0;

View file

@ -36,6 +36,7 @@
@import 'component/markdown-editor'; @import 'component/markdown-editor';
@import 'component/markdown-preview'; @import 'component/markdown-preview';
@import 'component/media'; @import 'component/media';
@import 'component/membership';
@import 'component/menu-button'; @import 'component/menu-button';
@import 'component/modal'; @import 'component/modal';
@import 'component/nag'; @import 'component/nag';

View file

@ -285,12 +285,27 @@ a.button--alt {
max-width: 100%; max-width: 100%;
text-align: left; text-align: left;
.comment__badge {
padding-right: 0px;
svg {
height: 1.2rem;
width: 1.2rem;
}
}
.channel-name { .channel-name {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
display: flex;
align-items: flex-end;
font-size: var(--font-xsmall); font-size: var(--font-xsmall);
color: rgba(var(--color-text-base), 0.6); color: rgba(var(--color-text-base), 0.6);
.icon {
margin-left: var(--spacing-xxs);
}
} }
.markdown-preview & { .markdown-preview & {

View file

@ -329,9 +329,10 @@
padding-top: 0; padding-top: 0;
} }
} }
@media (min-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
// padding: var(--spacing-s); .button--close {
// padding-right: 0px; top: calc(var(--spacing-m) * -1);
}
} }
} }

View file

@ -199,6 +199,37 @@ $actions-z-index: 2;
width: 5rem; width: 5rem;
background-size: cover; background-size: cover;
margin-right: var(--spacing-m); margin-right: var(--spacing-m);
.button {
display: flex;
position: absolute;
text-align: center;
width: 80%;
bottom: -16%;
left: unset !important;
background-color: unset !important;
right: -1rem;
}
.link--small {
right: -3rem !important;
}
.comment__badge {
position: absolute;
text-align: center;
width: 80%;
left: 22%;
bottom: -30%;
svg {
stroke: unset;
overflow: visible;
width: 100%;
height: 100%;
}
}
} }
.channel-thumbnail--resolving { .channel-thumbnail--resolving {
@ -237,6 +268,31 @@ $actions-z-index: 2;
top: 0; top: 0;
margin-top: var(--spacing-xl); margin-top: var(--spacing-xl);
} }
.comment__badge {
position: absolute;
text-align: center;
width: 60%;
left: 0;
svg {
stroke: unset;
overflow: visible;
width: 100%;
height: 100%;
}
}
}
.claim-preview .channel-thumbnail .comment__badge {
padding: 0px;
left: 12%;
bottom: -20%;
svg {
width: 3rem;
height: 3rem;
}
} }
.channel-thumbnail__custom { .channel-thumbnail__custom {
@ -332,6 +388,15 @@ $actions-z-index: 2;
@media (max-width: $breakpoint-small) { @media (max-width: $breakpoint-small) {
padding-top: var(--spacing-xl); padding-top: var(--spacing-xl);
} }
.comment__badge {
padding-right: 0px;
svg {
height: 2.5rem;
width: 2.5rem;
}
}
} }
.channel__meta { .channel__meta {
@ -501,6 +566,15 @@ $actions-z-index: 2;
padding: var(--spacing-s); padding: var(--spacing-s);
overflow: hidden; overflow: hidden;
.comment__badge {
padding-right: 0px;
svg {
height: 2rem;
width: 2rem;
}
}
.channel-thumbnail { .channel-thumbnail {
height: 2rem; height: 2rem;
width: 2rem; width: 2rem;
@ -634,6 +708,19 @@ $actions-z-index: 2;
} }
} }
.icon__wrapper--PremiumPlusBadge {
margin-left: 3px;
height: 22px !important;
width: 22px !important;
padding: 0 !important;
display: inline-block !important;
margin-bottom: -9px !important;
}
.icon__wrapper--PremiumPlusBadge > svg {
margin-top: -2px;
}
.channelsPage-wrapper { .channelsPage-wrapper {
.claim-preview__wrapper--channel { .claim-preview__wrapper--channel {
position: relative; position: relative;

View file

@ -8,16 +8,12 @@
} }
} }
.comment__badge--globalMod { // fix contrast on hover of channel selector, couldn't come up with a better way
.st0 { div[role='menuitem'] .channel__list-item .comment__badge svg {
// @see: ICONS.BADGE_MOD stroke: unset !important;
fill: #fe7500;
}
} }
.comment__badge--mod { // icon is a bit bright and loud
.st0 { .icon--PremiumPlus {
// @see: ICONS.BADGE_MOD filter: brightness(0.92);
fill: #ff3850;
}
} }

View file

@ -228,6 +228,11 @@ $thumbnailWidthSmall: 2rem;
color: rgba(var(--color-secondary-dynamic), 1); color: rgba(var(--color-secondary-dynamic), 1);
} }
} }
.comment__badge svg {
height: 1.4rem;
width: 1.4rem;
}
} }
.comment__pin { .comment__pin {

View file

@ -779,6 +779,17 @@ $recent-msg-button__height: 2rem;
color: var(--color-black); color: var(--color-black);
opacity: 0.9; opacity: 0.9;
} }
.channel-name {
max-width: unset;
p {
max-width: 5rem;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
} }
.livestreamSuperchat__info--notSticker { .livestreamSuperchat__info--notSticker {

View file

@ -86,6 +86,11 @@
.livestreamComment__info { .livestreamComment__info {
overflow: hidden; overflow: hidden;
.comment__badge svg {
height: 1.4rem;
width: 1.4rem;
}
} }
.livestream__comment--superchat { .livestream__comment--superchat {

View file

@ -0,0 +1,68 @@
.membership_title {
margin-top: var(--spacing-l);
font-size: 18px;
font-weight: var(--font-weight-bold);
.comment__badge {
margin-left: var(--spacing-s);
svg {
height: 2rem;
width: 2rem;
}
}
}
.membership_title:first-of-type {
margin-top: 0;
}
.membership_subtitle {
font-size: var(--font-small);
color: var(--color-text-subtitle);
margin-bottom: var(--spacing-s);
}
.membership_info {
font-size: var(--font-small);
}
.membership_button {
display: block;
margin-bottom: 18px;
margin-top: 10px;
}
.cancel-membership-button {
display: block;
margin-bottom: 18px;
margin-top: 13px;
}
.explanation-text {
margin-bottom: 16px;
}
.premium-explanation-text {
.icon--ChevronDown,
.icon--ChevronUp {
height: 24px;
width: 24px;
}
.card__main-actions {
margin-bottom: 15px;
}
.button--close {
margin-top: 3px;
left: 223px;
width: 34px;
}
.section__subtitle {
margin-bottom: 25px;
}
margin-bottom: 21px;
}

View file

@ -49,3 +49,353 @@
.doodle { .doodle {
position: fixed; position: fixed;
} }
// Membership Splash
.membership-splash {
padding-left: 10rem;
padding-right: 10rem;
display: flex;
flex-flow: column wrap;
justify-content: space-around;
transition: padding 0.6s;
::selection {
background-color: black;
color: white;
}
@media (min-width: $breakpoint-xlarge) {
padding-left: 0rem;
padding-right: 0rem;
margin-left: -16rem;
margin-right: -16rem;
}
@media (max-width: $breakpoint-medium) {
padding-left: 4rem;
padding-right: 4rem;
}
@media (max-width: $breakpoint-small) {
padding-left: unset;
padding-right: unset;
}
.membership-splash__banner {
display: flex;
flex-basis: 100%;
flex: auto;
align-items: center;
background: #283263;
margin-bottom: var(--spacing-xxs);
img {
display: flex;
width: 50%;
flex-basis: 50%;
}
@media (max-width: $breakpoint-small) {
flex-flow: column-reverse;
img {
width: 100%;
flex-basis: 100%;
}
}
}
.membership-splash__title {
display: inline-block;
width: 100%;
flex-basis: 50%;
padding: var(--spacing-l);
padding-top: 0;
padding-bottom: 0;
color: #fff;
// font-size: 2.6rem;
font-size: 2.3vw;
line-height: 2.8vw;
font-weight: 100;
b {
font-weight: 900;
}
section {
&:first-of-type {
margin-bottom: 2rem;
img {
width: 96%;
}
}
}
@media (max-width: $breakpoint-small) {
font-size: 2rem;
line-height: 2.1rem;
margin-bottom: 1.6rem;
section {
&:first-of-type {
img {
margin-top: 1.6rem;
}
}
}
}
}
.membership-splash__info-wrapper {
display: flex;
flex-basis: 100%;
.membership-splash__info {
position: relative;
background: #fff;
flex-basis: 33%;
color: #000;
padding-bottom: 4.2rem;
.membership-splash__info-content {
padding-left: var(--spacing-m);
}
}
@media (max-width: $breakpoint-small) {
flex-flow: column;
.membership-splash__info {
flex-basis: 100%;
margin-bottom: var(--spacing-xxs);
}
// "creating a revolutionary platform.." copy
.membership-splash__info:nth-of-type(1) {
line-height: 1.5rem;
}
}
.membership-splash__info:nth-child(1) {
// padding: var(--spacing-l);
padding: 3%;
font-size: 1vw;
@media (min-width: $breakpoint-xlarge) {
font-size: 1vw;
}
@media (max-width: $breakpoint-medium) {
font-size: 1.3vw;
}
@media (max-width: $breakpoint-small) {
font-size: 0.9rem;
}
}
.membership-splash__info:nth-child(2) {
margin-left: var(--spacing-xxs);
margin-right: var(--spacing-xxs);
.membership-splash__info-header {
background-color: #d5cee5;
color: #626092;
}
@media (max-width: $breakpoint-small) {
margin-left: 0;
margin-right: 0;
}
}
.membership-splash__info:nth-child(3) {
.membership-splash__info-header {
background-color: #ffd976;
color: #c95b16;
}
}
}
.membership-splash__info-header {
margin-bottom: 18px;
.membership-splash__info-price {
display: flex;
align-items: center;
font-size: 2.6rem;
font-size: 2.6vw;
font-weight: 900;
img {
display: inline-block;
width: 36%;
//margin-left: 1rem;
margin-left: 7%;
//margin-right: 1.4rem;
margin-right: 7%;
}
section {
display: inline-block;
}
.membership-splash__info-range {
font-size: 1rem;
margin-top: -10px;
}
@media (min-width: $breakpoint-xlarge) {
img {
//margin-left: 2%;
width: 33%;
}
.membership-splash__info-range {
font-size: 1.8rem;
margin-top: -1.6rem;
}
}
@media (max-width: $breakpoint-medium) {
img {
//margin-left: 2%;
width: 30%;
}
}
@media (max-width: $breakpoint-small) {
font-size: 2.2rem;
img {
margin-left: 1.4rem;
margin-right: 1.4rem;
width: 5rem;
}
}
}
}
.membership-splash__info-content {
display: flex;
align-items: center;
font-size: 1vw;
font-weight: 900;
margin-top: 8px;
.icon {
width: 2rem;
margin-right: var(--spacing-xs);
}
@media (max-width: $breakpoint-medium) {
font-size: 1.3vw;
}
@media (max-width: $breakpoint-small) {
font-size: 0.9rem;
}
}
.membership-splash__info-button {
text-align: center;
position: absolute;
width: calc(100% - (var(--spacing-m) * 2));
bottom: 0;
margin-left: var(--spacing-m);
margin-right: var(--spacing-m);
display: inline-block;
margin-bottom: var(--spacing-m);
.button--primary {
display: inline-block;
border: 2px solid #debca0;
border-radius: 20px;
padding: 8px 20px 8px 20px;
background-color: unset !important;
text-align: center;
.button__label {
align-self: center;
display: inline-block;
color: #debca0;
text-transform: uppercase;
font-size: 17px;
line-height: 2rem;
font-weight: var(--font-weight-bold);
}
&:hover {
background-color: unset;
}
@media (min-width: $breakpoint-xlarge) {
padding: 18px 40px 18px 40px;
height: 4.8rem;
border-radius: 2.4rem;
margin-bottom: 3%;
.button__label {
line-height: 4rem;
}
}
@media (max-width: $breakpoint-small) {
padding: 8px 10px 8px 10px;
box-sizing: border-box;
.button__label {
font-size: 1.2rem;
}
}
}
}
}
.modal {
@media (min-width: $breakpoint-small) {
.membership-splash {
padding-left: unset;
padding-right: unset;
.membership-splash__banner {
.membership-splash__title {
padding-left: var(--spacing-m);
padding-right: var(--spacing-m);
font-size: 1.6rem;
line-height: 1.7rem;
section:first-of-type {
margin-bottom: var(--spacing-m);
}
}
}
.membership-splash__info-wrapper {
.membership-splash__info {
.membership-splash__info-header {
.membership-splash__info-price {
img {
margin-left: var(--spacing-s);
margin-right: var(--spacing-s);
}
section {
font-size: 1.6rem;
}
.membership-splash__info-range {
font-size: 0.8rem;
margin-top: -10px;
}
}
}
.membership-splash__info-button {
margin-left: var(--spacing-xs);
.button {
padding: 4px 6px 4px 6px;
.button__content {
.button__label {
font-size: 0.74rem;
}
}
}
}
.membership-splash__info-content {
padding-left: var(--spacing-xxs);
font-size: 0.7rem;
.icon {
width: 1.7rem;
height: 1.7rem;
margin-right: var(--spacing-xxs);
}
}
}
.membership-splash__info:nth-child(1) {
padding: var(--spacing-m);
font-size: 0.7rem;
}
}
}
}
@media (max-width: $breakpoint-small) {
.membership-splash {
.membership-splash__banner {
.membership-splash__title {
img {
margin-top: var(--spacing-l);
}
}
}
}
}
}
.stripe__confirm-remove-membership {
.card__subtitle {
line-height: 39px;
margin-top: 5px;
margin-bottom: -1px;
}
}

View file

@ -43,3 +43,8 @@
color: var(--color-text) !important; color: var(--color-text) !important;
} }
} }
.profile-badge__tooltip {
margin-top: 0px !important;
right: 0.5rem !important;
}

View file

@ -1177,6 +1177,22 @@ img {
} }
} }
.premium-wrapper {
.membership_title {
.comment__badge {
.icon {
margin-bottom: -6px;
}
}
}
.premium-option {
background-color: rgba(var(--color-header-background-base), 0.4);
border-radius: var(--border-radius);
padding: var(--spacing-m);
margin-bottom: var(--spacing-m);
}
}
// Temporary master classes // Temporary master classes
.date_time { .date_time {
font-size: var(--font-xsmall); font-size: var(--font-xsmall);

View file

@ -1,14 +1,14 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { selectTheme } from 'redux/selectors/settings'; import { selectTheme } from 'redux/selectors/settings';
import { makeSelectClaimForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims'; import { makeSelectClaimForUri, selectClaimIsNsfwForUri } from 'redux/selectors/claims';
import { selectUserVerifiedEmail } from 'redux/selectors/user'; import { selectOdyseeMembershipIsPremiumPlus } from 'redux/selectors/user';
import Ads from './view'; import Ads from './view';
const select = (state, props) => ({ const select = (state, props) => ({
theme: selectTheme(state), theme: selectTheme(state),
claim: makeSelectClaimForUri(props.uri)(state), claim: makeSelectClaimForUri(props.uri)(state),
isMature: selectClaimIsNsfwForUri(state, props.uri), isMature: selectClaimIsNsfwForUri(state, props.uri),
authenticated: selectUserVerifiedEmail(state), userHasPremiumPlus: selectOdyseeMembershipIsPremiumPlus(state),
}); });
export default connect(select)(Ads); export default connect(select)(Ads);

View file

@ -40,7 +40,8 @@ type Props = {
small: boolean, small: boolean,
claim: Claim, claim: Claim,
isMature: boolean, isMature: boolean,
authenticated: boolean, triggerBlacklist: boolean,
userHasPremiumPlus: boolean,
className?: string, className?: string,
}; };
@ -64,16 +65,9 @@ function clearAdElements() {
} }
function Ads(props: Props) { function Ads(props: Props) {
const { const { type = 'video', tileLayout, small, userHasPremiumPlus, className } = props;
location: { pathname },
type = 'video',
tileLayout,
small,
authenticated,
className,
} = props;
const shouldShowAds = SHOW_ADS && !authenticated; const shouldShowAds = SHOW_ADS && !userHasPremiumPlus;
const mobileAds = IS_ANDROID || IS_IOS; const mobileAds = IS_ANDROID || IS_IOS;
// this is populated from app based on location // this is populated from app based on location
@ -106,8 +100,8 @@ function Ads(props: Props) {
log_in_to_domain: ( log_in_to_domain: (
<Button <Button
button="link" button="link"
label={__('Log in to %domain%', { domain: DOMAIN })} label={__('Get Odysee Premium+', { domain: DOMAIN })}
navigate={`/$/${PAGES.AUTH}?redirect=${pathname}`} navigate={`/$/${PAGES.ODYSEE_MEMBERSHIP}`}
/> />
), ),
}} }}