persisting subscriptions
This commit is contained in:
parent
f1f3d311e9
commit
a7fe02ea98
27 changed files with 180186 additions and 45 deletions
|
@ -16,5 +16,7 @@ module.name_mapper='^util\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/util\1'
|
||||||
module.name_mapper='^redux\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/redux\1'
|
module.name_mapper='^redux\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/redux\1'
|
||||||
module.name_mapper='^types\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/types\1'
|
module.name_mapper='^types\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/types\1'
|
||||||
module.name_mapper='^component\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/component\1'
|
module.name_mapper='^component\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/component\1'
|
||||||
|
module.name_mapper='^page\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/page\1'
|
||||||
|
module.name_mapper='^lbry\(.*\)$' -> '<PROJECT_ROOT>/src/renderer/lbry\1'
|
||||||
|
|
||||||
[strict]
|
[strict]
|
||||||
|
|
|
@ -9,6 +9,7 @@ Web UI version numbers should always match the corresponding version of LBRY App
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
* ShapeShift integration
|
* ShapeShift integration
|
||||||
|
* In-app subscriptions
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* The first run process for new users has changed substantially. New users can now easily receive one credit.
|
* The first run process for new users has changed substantially. New users can now easily receive one credit.
|
||||||
|
|
|
@ -41,6 +41,13 @@ export const Header = props => {
|
||||||
title={__("Discover Content")}
|
title={__("Discover Content")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="header__item">
|
||||||
|
<Link
|
||||||
|
onClick={() => navigate("/subscriptions")}
|
||||||
|
button="alt button--flat"
|
||||||
|
icon="icon-at"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="header__item header__item--wunderbar">
|
<div className="header__item header__item--wunderbar">
|
||||||
<WunderBar />
|
<WunderBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,6 +17,7 @@ import SearchPage from "page/search";
|
||||||
import AuthPage from "page/auth";
|
import AuthPage from "page/auth";
|
||||||
import InvitePage from "page/invite";
|
import InvitePage from "page/invite";
|
||||||
import BackupPage from "page/backup";
|
import BackupPage from "page/backup";
|
||||||
|
import SubscriptionsPage from "page/subscriptions";
|
||||||
|
|
||||||
const route = (page, routesMap) => {
|
const route = (page, routesMap) => {
|
||||||
const component = routesMap[page];
|
const component = routesMap[page];
|
||||||
|
@ -46,6 +47,7 @@ const Router = props => {
|
||||||
settings: <SettingsPage params={params} />,
|
settings: <SettingsPage params={params} />,
|
||||||
show: <ShowPage {...params} />,
|
show: <ShowPage {...params} />,
|
||||||
wallet: <WalletPage params={params} />,
|
wallet: <WalletPage params={params} />,
|
||||||
|
subscriptions: <SubscriptionsPage params={params} />,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Link from "component/link";
|
import Link from "component/link";
|
||||||
|
import classnames from "classnames";
|
||||||
|
|
||||||
const SubHeader = props => {
|
const SubHeader = props => {
|
||||||
const { subLinks, currentPage, navigate, modifier } = props;
|
const { subLinks, currentPage, navigate, fullWidth, smallMargin } = props;
|
||||||
|
|
||||||
const links = [];
|
const links = [];
|
||||||
|
|
||||||
|
@ -22,7 +23,10 @@ const SubHeader = props => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={"sub-header" + (modifier ? " sub-header--" + modifier : "")}
|
className={classnames("sub-header", {
|
||||||
|
"sub-header--full-width": fullWidth,
|
||||||
|
"sub-header--small-margin": smallMargin,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{links}
|
{links}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
@ -159,3 +159,8 @@ export const GET_ACTIVE_SHIFT_START = "GET_ACTIVE_SHIFT_START";
|
||||||
export const GET_ACTIVE_SHIFT_SUCCESS = "GET_ACTIVE_SHIFT_SUCCESS";
|
export const GET_ACTIVE_SHIFT_SUCCESS = "GET_ACTIVE_SHIFT_SUCCESS";
|
||||||
export const GET_ACTIVE_SHIFT_FAIL = "GET_ACTIVE_SHIFT_FAIL";
|
export const GET_ACTIVE_SHIFT_FAIL = "GET_ACTIVE_SHIFT_FAIL";
|
||||||
export const CLEAR_SHAPE_SHIFT = "CLEAR_SHAPE_SHIFT";
|
export const CLEAR_SHAPE_SHIFT = "CLEAR_SHAPE_SHIFT";
|
||||||
|
|
||||||
|
// Subscriptions
|
||||||
|
export const CHANNEL_SUBSCRIBE = "CHANNEL_SUBSCRIBE";
|
||||||
|
export const CHANNEL_UNSUBSCRIBE = "CHANNEL_UNSUBSCRIBE";
|
||||||
|
export const HAS_FETCHED_SUBSCRIPTIONS = "HAS_FETCHED_SUBSCRIPTIONS";
|
||||||
|
|
3318
src/renderer/dist/css/all.css
vendored
Normal file
3318
src/renderer/dist/css/all.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
0
src/renderer/dist/css/mixin/link.css
vendored
Normal file
0
src/renderer/dist/css/mixin/link.css
vendored
Normal file
176325
src/renderer/dist/js/bundle.js
vendored
Normal file
176325
src/renderer/dist/js/bundle.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
src/renderer/flow-typed/reselect.js
vendored
Normal file
3
src/renderer/flow-typed/reselect.js
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
declare module 'reselect' {
|
||||||
|
declare module.exports: any;
|
||||||
|
}
|
|
@ -15,6 +15,11 @@ import {
|
||||||
} from "redux/selectors/navigation";
|
} from "redux/selectors/navigation";
|
||||||
import { doNavigate } from "redux/actions/navigation";
|
import { doNavigate } from "redux/actions/navigation";
|
||||||
import { makeSelectTotalPagesForChannel } from "redux/selectors/content";
|
import { makeSelectTotalPagesForChannel } from "redux/selectors/content";
|
||||||
|
import { selectSubscriptions } from "redux/selectors/subscriptions";
|
||||||
|
import {
|
||||||
|
channelSubscribe,
|
||||||
|
channelUnsubscribe,
|
||||||
|
} from "redux/actions/subscriptions";
|
||||||
import ChannelPage from "./view";
|
import ChannelPage from "./view";
|
||||||
|
|
||||||
const select = (state, props) => ({
|
const select = (state, props) => ({
|
||||||
|
@ -24,12 +29,16 @@ const select = (state, props) => ({
|
||||||
page: makeSelectCurrentParam("page")(state),
|
page: makeSelectCurrentParam("page")(state),
|
||||||
params: selectCurrentParams(state),
|
params: selectCurrentParams(state),
|
||||||
totalPages: makeSelectTotalPagesForChannel(props.uri)(state),
|
totalPages: makeSelectTotalPagesForChannel(props.uri)(state),
|
||||||
|
subscriptions: selectSubscriptions(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
const perform = dispatch => ({
|
const perform = dispatch => ({
|
||||||
fetchClaims: (uri, page) => dispatch(doFetchClaimsByChannel(uri, page)),
|
fetchClaims: (uri, page) => dispatch(doFetchClaimsByChannel(uri, page)),
|
||||||
fetchClaimCount: uri => dispatch(doFetchClaimCountByChannel(uri)),
|
fetchClaimCount: uri => dispatch(doFetchClaimCountByChannel(uri)),
|
||||||
navigate: (path, params) => dispatch(doNavigate(path, params)),
|
navigate: (path, params) => dispatch(doNavigate(path, params)),
|
||||||
|
channelSubscribe: subscription => dispatch(channelSubscribe(subscription)),
|
||||||
|
channelUnsubscribe: subscription =>
|
||||||
|
dispatch(channelUnsubscribe(subscription)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(select, perform)(ChannelPage);
|
export default connect(select, perform)(ChannelPage);
|
||||||
|
|
|
@ -3,6 +3,7 @@ import lbryuri from "lbryuri";
|
||||||
import { BusyMessage } from "component/common";
|
import { BusyMessage } from "component/common";
|
||||||
import FileTile from "component/fileTile";
|
import FileTile from "component/fileTile";
|
||||||
import ReactPaginate from "react-paginate";
|
import ReactPaginate from "react-paginate";
|
||||||
|
import Link from "component/link";
|
||||||
|
|
||||||
class ChannelPage extends React.PureComponent {
|
class ChannelPage extends React.PureComponent {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
@ -38,8 +39,28 @@ class ChannelPage extends React.PureComponent {
|
||||||
uri,
|
uri,
|
||||||
page,
|
page,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
channelSubscribe,
|
||||||
|
channelUnsubscribe,
|
||||||
|
subscriptions,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
const { name, claim_id: claimId } = claim;
|
||||||
|
|
||||||
|
const isSubscribed =
|
||||||
|
subscriptions
|
||||||
|
.map(subscription => subscription.channelName)
|
||||||
|
.indexOf(name) !== -1;
|
||||||
|
|
||||||
|
const subscriptionHandler = isSubscribed
|
||||||
|
? channelUnsubscribe
|
||||||
|
: channelSubscribe;
|
||||||
|
|
||||||
|
const subscriptionLabel = isSubscribed ? "Unsubscribe" : "Subscribe";
|
||||||
|
const subscriptionUri = lbryuri.build(
|
||||||
|
{ channelName: name, claimId },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
let contentList;
|
let contentList;
|
||||||
if (fetching) {
|
if (fetching) {
|
||||||
contentList = <BusyMessage message={__("Fetching content")} />;
|
contentList = <BusyMessage message={__("Fetching content")} />;
|
||||||
|
@ -68,6 +89,18 @@ class ChannelPage extends React.PureComponent {
|
||||||
<div className="card__title-identity">
|
<div className="card__title-identity">
|
||||||
<h1>{uri}</h1>
|
<h1>{uri}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card__actions">
|
||||||
|
<Link
|
||||||
|
button="primary"
|
||||||
|
label={subscriptionLabel}
|
||||||
|
onClick={() =>
|
||||||
|
subscriptionHandler({
|
||||||
|
channelName: name,
|
||||||
|
uri: subscriptionUri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card__content">
|
<div className="card__content">
|
||||||
<p className="empty">
|
<p className="empty">
|
||||||
|
|
|
@ -4,15 +4,42 @@ import lbryuri from "lbryuri";
|
||||||
import FileCard from "component/fileCard";
|
import FileCard from "component/fileCard";
|
||||||
import { Icon, BusyMessage } from "component/common.js";
|
import { Icon, BusyMessage } from "component/common.js";
|
||||||
import ToolTip from "component/tooltip.js";
|
import ToolTip from "component/tooltip.js";
|
||||||
|
import SubHeader from "component/subHeader";
|
||||||
|
import classnames from "classnames";
|
||||||
|
import Link from "component/link";
|
||||||
|
|
||||||
|
// This should be in a separate file
|
||||||
|
export class FeaturedCategory extends React.PureComponent {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
numItems: undefined,
|
||||||
|
canScrollPrevious: false,
|
||||||
|
canScrollNext: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class FeaturedCategory extends React.PureComponent {
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
this.setState({
|
this.setState({
|
||||||
numItems: this.props.names.length,
|
numItems: this.props.names.length,
|
||||||
canScrollPrevious: false,
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const cardRow = ReactDOM.findDOMNode(this.refs.rowitems);
|
||||||
|
const cards = cardRow.getElementsByTagName("section");
|
||||||
|
|
||||||
|
// check if the last card is visible
|
||||||
|
const lastCard = cards[cards.length - 1];
|
||||||
|
const isCompletelyVisible = this.isCardVisible(lastCard, cardRow, false);
|
||||||
|
|
||||||
|
if (!isCompletelyVisible) {
|
||||||
|
this.setState({
|
||||||
canScrollNext: true,
|
canScrollNext: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleScrollPrevious() {
|
handleScrollPrevious() {
|
||||||
const cardRow = ReactDOM.findDOMNode(this.refs.rowitems);
|
const cardRow = ReactDOM.findDOMNode(this.refs.rowitems);
|
||||||
|
@ -148,12 +175,21 @@ class FeaturedCategory extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { category, names } = this.props;
|
const { category, names, categoryLink } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card-row card-row--small">
|
<div className="card-row card-row--small">
|
||||||
<h3 className="card-row__header">
|
<h3 className="card-row__header">
|
||||||
{category}
|
{categoryLink ? (
|
||||||
|
<Link
|
||||||
|
className="no-underline"
|
||||||
|
label={category}
|
||||||
|
href={categoryLink}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
category
|
||||||
|
)}
|
||||||
|
|
||||||
{category &&
|
{category &&
|
||||||
category.match(/^community/i) && (
|
category.match(/^community/i) && (
|
||||||
<ToolTip
|
<ToolTip
|
||||||
|
@ -214,15 +250,19 @@ class DiscoverPage extends React.PureComponent {
|
||||||
failedToLoad = !fetchingFeaturedUris && !hasContent;
|
failedToLoad = !fetchingFeaturedUris && !hasContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={hasContent && fetchingFeaturedUris ? "reloading" : null}>
|
<main
|
||||||
|
className={classnames("main main--no-margin", {
|
||||||
|
reloading: hasContent && fetchingFeaturedUris,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SubHeader fullWidth smallMargin />
|
||||||
{!hasContent &&
|
{!hasContent &&
|
||||||
fetchingFeaturedUris && (
|
fetchingFeaturedUris && (
|
||||||
<BusyMessage message={__("Fetching content")} />
|
<BusyMessage message={__("Fetching content")} />
|
||||||
)}
|
)}
|
||||||
{hasContent &&
|
{hasContent &&
|
||||||
Object.keys(featuredUris).map(
|
Object.keys(featuredUris).map(category => {
|
||||||
category =>
|
return featuredUris[category].length ? (
|
||||||
featuredUris[category].length ? (
|
|
||||||
<FeaturedCategory
|
<FeaturedCategory
|
||||||
key={category}
|
key={category}
|
||||||
category={category}
|
category={category}
|
||||||
|
@ -230,8 +270,8 @@ class DiscoverPage extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
""
|
""
|
||||||
)
|
);
|
||||||
)}
|
})}
|
||||||
{failedToLoad && (
|
{failedToLoad && (
|
||||||
<div className="empty">{__("Failed to load landing content.")}</div>
|
<div className="empty">{__("Failed to load landing content.")}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -2,8 +2,13 @@ import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { doNavigate } from "redux/actions/navigation";
|
import { doNavigate } from "redux/actions/navigation";
|
||||||
import { doFetchFileInfo } from "redux/actions/file_info";
|
import { doFetchFileInfo } from "redux/actions/file_info";
|
||||||
|
import {
|
||||||
|
channelSubscribe,
|
||||||
|
channelUnsubscribe,
|
||||||
|
} from "redux/actions/subscriptions";
|
||||||
import { makeSelectFileInfoForUri } from "redux/selectors/file_info";
|
import { makeSelectFileInfoForUri } from "redux/selectors/file_info";
|
||||||
import { selectRewardContentClaimIds } from "redux/selectors/content";
|
import { selectRewardContentClaimIds } from "redux/selectors/content";
|
||||||
|
import { selectSubscriptions } from "redux/selectors/subscriptions";
|
||||||
import { doFetchCostInfoForUri } from "redux/actions/cost_info";
|
import { doFetchCostInfoForUri } from "redux/actions/cost_info";
|
||||||
import {
|
import {
|
||||||
makeSelectClaimForUri,
|
makeSelectClaimForUri,
|
||||||
|
@ -24,12 +29,16 @@ const select = (state, props) => ({
|
||||||
tab: makeSelectCurrentParam("tab")(state),
|
tab: makeSelectCurrentParam("tab")(state),
|
||||||
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
|
fileInfo: makeSelectFileInfoForUri(props.uri)(state),
|
||||||
rewardedContentClaimIds: selectRewardContentClaimIds(state, props),
|
rewardedContentClaimIds: selectRewardContentClaimIds(state, props),
|
||||||
|
subscriptions: selectSubscriptions(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
const perform = dispatch => ({
|
const perform = dispatch => ({
|
||||||
navigate: (path, params) => dispatch(doNavigate(path, params)),
|
navigate: (path, params) => dispatch(doNavigate(path, params)),
|
||||||
fetchFileInfo: uri => dispatch(doFetchFileInfo(uri)),
|
fetchFileInfo: uri => dispatch(doFetchFileInfo(uri)),
|
||||||
fetchCostInfo: uri => dispatch(doFetchCostInfoForUri(uri)),
|
fetchCostInfo: uri => dispatch(doFetchCostInfoForUri(uri)),
|
||||||
|
channelSubscribe: subscription => dispatch(channelSubscribe(subscription)),
|
||||||
|
channelUnsubscribe: subscription =>
|
||||||
|
dispatch(channelUnsubscribe(subscription)),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(select, perform)(FilePage);
|
export default connect(select, perform)(FilePage);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import lbry from "lbry.js";
|
import lbry from "lbry";
|
||||||
import lbryuri from "lbryuri.js";
|
import lbryuri from "lbryuri";
|
||||||
import Video from "component/video";
|
import Video from "component/video";
|
||||||
import { Thumbnail } from "component/common";
|
import { Thumbnail } from "component/common";
|
||||||
import FilePrice from "component/filePrice";
|
import FilePrice from "component/filePrice";
|
||||||
|
@ -10,6 +10,7 @@ import Icon from "component/icon";
|
||||||
import WalletSendTip from "component/walletSendTip";
|
import WalletSendTip from "component/walletSendTip";
|
||||||
import DateTime from "component/dateTime";
|
import DateTime from "component/dateTime";
|
||||||
import * as icons from "constants/icons";
|
import * as icons from "constants/icons";
|
||||||
|
import Link from "component/link";
|
||||||
|
|
||||||
class FilePage extends React.PureComponent {
|
class FilePage extends React.PureComponent {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
@ -42,6 +43,9 @@ class FilePage extends React.PureComponent {
|
||||||
tab,
|
tab,
|
||||||
uri,
|
uri,
|
||||||
rewardedContentClaimIds,
|
rewardedContentClaimIds,
|
||||||
|
channelSubscribe,
|
||||||
|
channelUnsubscribe,
|
||||||
|
subscriptions,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const showTipBox = tab == "tip";
|
const showTipBox = tab == "tip";
|
||||||
|
@ -52,7 +56,7 @@ class FilePage extends React.PureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { height } = claim;
|
const { height, channel_name: channelName, value } = claim;
|
||||||
const title = metadata.title;
|
const title = metadata.title;
|
||||||
const isRewardContent = rewardedContentClaimIds.includes(claim.claim_id);
|
const isRewardContent = rewardedContentClaimIds.includes(claim.claim_id);
|
||||||
const mediaType = lbry.getMediaType(contentType);
|
const mediaType = lbry.getMediaType(contentType);
|
||||||
|
@ -62,6 +66,35 @@ class FilePage extends React.PureComponent {
|
||||||
Object.values(player.mime).indexOf(contentType) !== -1 ||
|
Object.values(player.mime).indexOf(contentType) !== -1 ||
|
||||||
mediaType === "audio";
|
mediaType === "audio";
|
||||||
|
|
||||||
|
const channelClaimId =
|
||||||
|
value &&
|
||||||
|
value.publisherSignature &&
|
||||||
|
value.publisherSignature.certificateId;
|
||||||
|
|
||||||
|
const canSubscribe = !!channelName && !!channelClaimId;
|
||||||
|
|
||||||
|
let isSubscribed;
|
||||||
|
let subscriptionHandler;
|
||||||
|
let subscriptionLabel;
|
||||||
|
let subscriptionUri;
|
||||||
|
|
||||||
|
if (canSubscribe) {
|
||||||
|
isSubscribed =
|
||||||
|
subscriptions
|
||||||
|
.map(subscription => subscription.channelName)
|
||||||
|
.indexOf(channelName) !== -1;
|
||||||
|
|
||||||
|
subscriptionHandler = isSubscribed
|
||||||
|
? channelUnsubscribe
|
||||||
|
: channelSubscribe;
|
||||||
|
|
||||||
|
subscriptionLabel = isSubscribed ? "Unsubscribe" : "Subscribe";
|
||||||
|
subscriptionUri = lbryuri.build(
|
||||||
|
{ channelName, claimId: channelClaimId },
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={"card " + (obscureNsfw ? "card--obscured " : "")}>
|
<section className={"card " + (obscureNsfw ? "card--obscured " : "")}>
|
||||||
<div className="show-page-media">
|
<div className="show-page-media">
|
||||||
|
@ -98,6 +131,20 @@ class FilePage extends React.PureComponent {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{canSubscribe && (
|
||||||
|
<div className="card__actions">
|
||||||
|
<Link
|
||||||
|
button="primary"
|
||||||
|
onClick={() =>
|
||||||
|
subscriptionHandler({
|
||||||
|
channelName,
|
||||||
|
uri: subscriptionUri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label={subscriptionLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<FileDetails uri={uri} />
|
<FileDetails uri={uri} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
21
src/renderer/page/subscriptions/index.js
Normal file
21
src/renderer/page/subscriptions/index.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React from "react";
|
||||||
|
import { connect } from "react-redux";
|
||||||
|
import {
|
||||||
|
selectSubscriptionsFromClaims,
|
||||||
|
selectSubscriptions,
|
||||||
|
selectHasFetchedSubscriptions
|
||||||
|
} from "redux/selectors/subscriptions";
|
||||||
|
import { doFetchClaimsByChannel } from "redux/actions/content";
|
||||||
|
import { setHasFetchedSubscriptions } from "redux/actions/subscriptions";
|
||||||
|
import SubscriptionsPage from "./view";
|
||||||
|
|
||||||
|
const select = state => ({
|
||||||
|
hasFetchedSubscriptions: state.subscriptions.hasFetchedSubscriptions,
|
||||||
|
savedSubscriptions: selectSubscriptions(state),
|
||||||
|
subscriptions: selectSubscriptionsFromClaims(state),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default connect(select, {
|
||||||
|
doFetchClaimsByChannel,
|
||||||
|
setHasFetchedSubscriptions
|
||||||
|
})(SubscriptionsPage);
|
114
src/renderer/page/subscriptions/view.jsx
Normal file
114
src/renderer/page/subscriptions/view.jsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
// @flow
|
||||||
|
import React from "react";
|
||||||
|
import SubHeader from "component/subHeader";
|
||||||
|
import { BusyMessage } from "component/common.js";
|
||||||
|
import { FeaturedCategory } from "page/discover/view";
|
||||||
|
import type { Subscription } from "redux/reducers/subscriptions";
|
||||||
|
|
||||||
|
type SavedSubscriptions = Array<Subscription>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
doFetchClaimsByChannel: (string, number) => any,
|
||||||
|
savedSubscriptions: SavedSubscriptions,
|
||||||
|
// TODO build out claim types
|
||||||
|
subscriptions: Array<any>,
|
||||||
|
setHasFetchedSubscriptions: () => void,
|
||||||
|
hasFetchedSubscriptions: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class extends React.PureComponent<Props> {
|
||||||
|
// setHasFetchedSubscriptions is a terrible hack
|
||||||
|
// it allows the subscriptions to load correctly when refresing on the subscriptions page
|
||||||
|
// currently the page is rendered before the state is rehyrdated
|
||||||
|
// that causes this component to be rendered with zero savedSubscriptions
|
||||||
|
// we need to wait until persist/REHYDRATE has fired before rendering the page
|
||||||
|
componentDidMount() {
|
||||||
|
const { savedSubscriptions, setHasFetchedSubscriptions } = this.props;
|
||||||
|
if (savedSubscriptions.length) {
|
||||||
|
this.fetchSubscriptions(savedSubscriptions);
|
||||||
|
setHasFetchedSubscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(props: Props) {
|
||||||
|
const {
|
||||||
|
savedSubscriptions,
|
||||||
|
hasFetchedSubscriptions,
|
||||||
|
setHasFetchedSubscriptions,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
if (!hasFetchedSubscriptions && savedSubscriptions.length) {
|
||||||
|
this.fetchSubscriptions(savedSubscriptions);
|
||||||
|
setHasFetchedSubscriptions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSubscriptions(savedSubscriptions: SavedSubscriptions) {
|
||||||
|
const { doFetchClaimsByChannel } = this.props;
|
||||||
|
if (savedSubscriptions.length) {
|
||||||
|
// can this use batchActions?
|
||||||
|
savedSubscriptions.forEach(sub => {
|
||||||
|
doFetchClaimsByChannel(sub.uri, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { subscriptions, savedSubscriptions } = this.props;
|
||||||
|
|
||||||
|
let someClaimsNotLoaded;
|
||||||
|
for (var i = 0; i < subscriptions.length; i++) {
|
||||||
|
const subscription = subscriptions[i];
|
||||||
|
if (!subscription.claims.length) {
|
||||||
|
someClaimsNotLoaded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchingSubscriptions =
|
||||||
|
!!savedSubscriptions.length &&
|
||||||
|
(subscriptions.length !== savedSubscriptions.length ||
|
||||||
|
someClaimsNotLoaded);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="main main--no-margin">
|
||||||
|
<SubHeader fullWidth smallMargin />
|
||||||
|
{!savedSubscriptions.length && (
|
||||||
|
<span>{__("You haven't subscribed to any channels yet")}</span>
|
||||||
|
)}
|
||||||
|
{fetchingSubscriptions && (
|
||||||
|
<div className="card-row__placeholder">
|
||||||
|
<BusyMessage message={__("Fetching subscriptions")} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!savedSubscriptions.length && (
|
||||||
|
<div>
|
||||||
|
{!!subscriptions.length &&
|
||||||
|
subscriptions.map(subscription => {
|
||||||
|
if (!subscription.claims.length) {
|
||||||
|
// will need to update when you can subscribe to empty channels
|
||||||
|
// for now this prevents issues with FeaturedCategory being rendered
|
||||||
|
// before the names (claim uris) are populated
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// creating uris for each subscription file
|
||||||
|
const names = subscription.claims.slice().map(claim => {
|
||||||
|
return `${claim.name}#${claim.claim_id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeaturedCategory
|
||||||
|
key={subscription.channelName}
|
||||||
|
categoryLink={`lbry://${subscription.uri}`}
|
||||||
|
category={subscription.channelName}
|
||||||
|
names={names}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
26
src/renderer/redux/actions/subscriptions.js
Normal file
26
src/renderer/redux/actions/subscriptions.js
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// @flow
|
||||||
|
import * as actions from "constants/action_types";
|
||||||
|
import type { Subscription, Action, Dispatch } from "redux/reducers/subscriptions";
|
||||||
|
import lbry from "lbry";
|
||||||
|
|
||||||
|
export const channelSubscribe = (subscription: Subscription) => (
|
||||||
|
dispatch: Dispatch
|
||||||
|
) => {
|
||||||
|
return dispatch({
|
||||||
|
type: actions.CHANNEL_SUBSCRIBE,
|
||||||
|
data: subscription,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const channelUnsubscribe = (subscription: Subscription) => (
|
||||||
|
dispatch: Dispatch
|
||||||
|
) => {
|
||||||
|
return dispatch({
|
||||||
|
type: actions.CHANNEL_UNSUBSCRIBE,
|
||||||
|
data: subscription,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setHasFetchedSubscriptions = () => (dispatch: Dispatch) => {
|
||||||
|
return dispatch({ type: actions.HAS_FETCHED_SUBSCRIPTIONS })
|
||||||
|
}
|
80
src/renderer/redux/reducers/subscriptions.js
Normal file
80
src/renderer/redux/reducers/subscriptions.js
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
// @flow
|
||||||
|
import * as actions from "constants/action_types";
|
||||||
|
import { handleActions } from "util/redux-utils";
|
||||||
|
|
||||||
|
// Subscription redux types
|
||||||
|
export type SubscriptionState = {
|
||||||
|
subscriptions: Array<Subscription>,
|
||||||
|
hasFetchedSubscriptions: boolean
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Subscription = {
|
||||||
|
channelName: string,
|
||||||
|
uri: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscription action types
|
||||||
|
type ChannelSubscribe = {
|
||||||
|
type: actions.CHANNEL_SUBSCRIBE,
|
||||||
|
data: Subscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChannelUnsubscribe = {
|
||||||
|
type: actions.CHANNEL_UNSUBSCRIBE,
|
||||||
|
data: Subscription,
|
||||||
|
};
|
||||||
|
|
||||||
|
type HasFetchedSubscriptions = {
|
||||||
|
type: actions.HAS_FETCHED_SUBSCRIPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Action = ChannelSubscribe | ChannelUnsubscribe | HasFetchedSubscriptions;
|
||||||
|
export type Dispatch = (action: Action) => any;
|
||||||
|
|
||||||
|
const defaultState = {
|
||||||
|
subscriptions: [],
|
||||||
|
hasFetchedSubscriptions: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handleActions(
|
||||||
|
{
|
||||||
|
[actions.CHANNEL_SUBSCRIBE]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: ChannelSubscribe
|
||||||
|
): SubscriptionState => {
|
||||||
|
const newSubscription: Subscription = action.data;
|
||||||
|
let newSubscriptions: Array<Subscription> = state.subscriptions.slice();
|
||||||
|
newSubscriptions.unshift(newSubscription);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subscriptions: newSubscriptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.CHANNEL_UNSUBSCRIBE]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: ChannelUnsubscribe
|
||||||
|
): SubscriptionState => {
|
||||||
|
const subscriptionToRemove: Subscription = action.data;
|
||||||
|
|
||||||
|
const newSubscriptions = state.subscriptions
|
||||||
|
.slice()
|
||||||
|
.filter(subscription => {
|
||||||
|
return subscription.channelName !== subscriptionToRemove.channelName;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
subscriptions: newSubscriptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.HAS_FETCHED_SUBSCRIPTIONS]: (
|
||||||
|
state: SubscriptionState,
|
||||||
|
action: HasFetchedSubscriptions
|
||||||
|
): SubscriptionState => ({
|
||||||
|
...state,
|
||||||
|
hasFetchedSubscriptions: true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
defaultState
|
||||||
|
);
|
|
@ -61,6 +61,12 @@ export const selectHeaderLinks = createSelector(selectCurrentPage, page => {
|
||||||
settings: __("Settings"),
|
settings: __("Settings"),
|
||||||
help: __("Help"),
|
help: __("Help"),
|
||||||
};
|
};
|
||||||
|
case "discover":
|
||||||
|
case "subscriptions":
|
||||||
|
return {
|
||||||
|
discover: __("Home"),
|
||||||
|
subscriptions: __("Subscriptions"),
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -111,6 +117,8 @@ export const selectPageTitle = createSelector(
|
||||||
return params.query
|
return params.query
|
||||||
? __("Search results for %s", params.query)
|
? __("Search results for %s", params.query)
|
||||||
: __("Search");
|
: __("Search");
|
||||||
|
case "subscriptions":
|
||||||
|
return __("Your Subscriptions");
|
||||||
case "discover":
|
case "discover":
|
||||||
case false:
|
case false:
|
||||||
case null:
|
case null:
|
||||||
|
|
|
@ -77,6 +77,8 @@ export const selectWunderBarIcon = createSelector(
|
||||||
case "discover":
|
case "discover":
|
||||||
case "search":
|
case "search":
|
||||||
return "icon-search";
|
return "icon-search";
|
||||||
|
case "subscriptions":
|
||||||
|
return "icon-th-list";
|
||||||
default:
|
default:
|
||||||
return "icon-file";
|
return "icon-file";
|
||||||
}
|
}
|
||||||
|
|
51
src/renderer/redux/selectors/subscriptions.js
Normal file
51
src/renderer/redux/selectors/subscriptions.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import * as settings from "constants/settings";
|
||||||
|
import { createSelector } from "reselect";
|
||||||
|
import { selectAllClaimsByChannel, selectClaimsById } from "./claims";
|
||||||
|
|
||||||
|
// get the entire subscriptions state
|
||||||
|
const _selectState = state => state.subscriptions || {};
|
||||||
|
|
||||||
|
// list of saved channel names and uris
|
||||||
|
export const selectSubscriptions = createSelector(
|
||||||
|
_selectState,
|
||||||
|
state => state.subscriptions
|
||||||
|
);
|
||||||
|
|
||||||
|
export const selectSubscriptionsFromClaims = createSelector(
|
||||||
|
selectAllClaimsByChannel,
|
||||||
|
selectClaimsById,
|
||||||
|
selectSubscriptions,
|
||||||
|
(channelIds, allClaims, savedSubscriptions) => {
|
||||||
|
// no claims loaded yet
|
||||||
|
if (!Object.keys(channelIds).length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let fetchedSubscriptions = [];
|
||||||
|
|
||||||
|
savedSubscriptions.forEach(subscription => {
|
||||||
|
let channelClaims = [];
|
||||||
|
|
||||||
|
// if subscribed channel has content
|
||||||
|
if (channelIds[subscription.uri]) {
|
||||||
|
// This will need to be more robust, we will want to be able to load more than the first page
|
||||||
|
const pageOneChannelIds = channelIds[subscription.uri]["1"];
|
||||||
|
|
||||||
|
// we have the channel ids and the corresponding claims
|
||||||
|
// loop over the list of ids and grab the claim
|
||||||
|
pageOneChannelIds.forEach(id => {
|
||||||
|
const grabbedClaim = allClaims[id];
|
||||||
|
channelClaims.push(grabbedClaim);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedSubscriptions.push({
|
||||||
|
claims: channelClaims,
|
||||||
|
channelName: subscription.channelName,
|
||||||
|
uri: subscription.uri,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return fetchedSubscriptions;
|
||||||
|
}
|
||||||
|
);
|
|
@ -62,6 +62,11 @@ body
|
||||||
{
|
{
|
||||||
width: $width-page-constrained;
|
width: $width-page-constrained;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
main.main--no-margin {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reloading {
|
.reloading {
|
||||||
|
|
|
@ -233,6 +233,10 @@ $font-size-subtext-multiple: 0.82;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-row__placeholder {
|
||||||
|
padding-bottom: $spacing-vertical;
|
||||||
|
}
|
||||||
|
|
||||||
$padding-top-card-hover-hack: 20px;
|
$padding-top-card-hover-hack: 20px;
|
||||||
$padding-right-card-hover-hack: 30px;
|
$padding-right-card-hover-hack: 30px;
|
||||||
|
|
||||||
|
|
|
@ -50,8 +50,17 @@ nav.sub-header
|
||||||
color: var(--tab-active-color);
|
color: var(--tab-active-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.sub-header--full-width {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sub-header--small-margin {
|
||||||
|
margin-bottom: $spacing-vertical;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@keyframes activeTab {
|
@keyframes activeTab {
|
||||||
from {width: 0;}
|
from {width: 0;}
|
||||||
to {width: 100%;}
|
to {width: 100%;}
|
||||||
|
|
|
@ -12,16 +12,15 @@ import settingsReducer from "redux/reducers/settings";
|
||||||
import userReducer from "redux/reducers/user";
|
import userReducer from "redux/reducers/user";
|
||||||
import walletReducer from "redux/reducers/wallet";
|
import walletReducer from "redux/reducers/wallet";
|
||||||
import shapeShiftReducer from "redux/reducers/shape_shift";
|
import shapeShiftReducer from "redux/reducers/shape_shift";
|
||||||
|
import subscriptionsReducer from "redux/reducers/subscriptions";
|
||||||
import { persistStore, autoRehydrate } from "redux-persist";
|
import { persistStore, autoRehydrate } from "redux-persist";
|
||||||
import createCompressor from "redux-persist-transform-compress";
|
import createCompressor from "redux-persist-transform-compress";
|
||||||
import createFilter from "redux-persist-transform-filter";
|
import createFilter from "redux-persist-transform-filter";
|
||||||
//import { REHYDRATE } from "redux-persist/constants";
|
import localForage from "localforage";
|
||||||
//import createActionBuffer from "redux-action-buffer";
|
import { createStore, applyMiddleware, compose, combineReducers } from "redux";
|
||||||
|
import thunk from "redux-thunk";
|
||||||
|
|
||||||
const localForage = require("localforage");
|
const env = process.env.NODE_ENV || "production"
|
||||||
const redux = require("redux");
|
|
||||||
const thunk = require("redux-thunk").default;
|
|
||||||
const env = process.env.NODE_ENV || "production";
|
|
||||||
|
|
||||||
function isFunction(object) {
|
function isFunction(object) {
|
||||||
return typeof object === "function";
|
return typeof object === "function";
|
||||||
|
@ -55,7 +54,7 @@ function enableBatching(reducer) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const reducers = redux.combineReducers({
|
const reducers = combineReducers({
|
||||||
app: appReducer,
|
app: appReducer,
|
||||||
navigation: navigationReducer,
|
navigation: navigationReducer,
|
||||||
availability: availabilityReducer,
|
availability: availabilityReducer,
|
||||||
|
@ -69,6 +68,7 @@ const reducers = redux.combineReducers({
|
||||||
wallet: walletReducer,
|
wallet: walletReducer,
|
||||||
user: userReducer,
|
user: userReducer,
|
||||||
shapeShift: shapeShiftReducer,
|
shapeShift: shapeShiftReducer,
|
||||||
|
subscriptions: subscriptionsReducer,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bulkThunk = createBulkThunkMiddleware();
|
const bulkThunk = createBulkThunkMiddleware();
|
||||||
|
@ -81,26 +81,36 @@ if (env === "development") {
|
||||||
middleware.push(logger);
|
middleware.push(logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
// middleware.push(createActionBuffer(REHYDRATE)); // was causing issues with authentication reducers not firing
|
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
const composeEnhancers =
|
|
||||||
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || redux.compose;
|
const store = createStore(
|
||||||
const createStoreWithMiddleware = composeEnhancers(
|
enableBatching(reducers),
|
||||||
autoRehydrate(),
|
{}, // initial state
|
||||||
redux.applyMiddleware(...middleware)
|
composeEnhancers(
|
||||||
)(redux.createStore);
|
autoRehydrate({
|
||||||
|
log: env === "development",
|
||||||
|
}),
|
||||||
|
applyMiddleware(...middleware)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const reduxStore = createStoreWithMiddleware(enableBatching(reducers));
|
|
||||||
const compressor = createCompressor();
|
const compressor = createCompressor();
|
||||||
const saveClaimsFilter = createFilter("claims", ["byId", "claimsByUri"]);
|
const saveClaimsFilter = createFilter("claims", ["byId", "claimsByUri"]);
|
||||||
|
const subscriptionsFilter = createFilter("subscriptions", ["subscriptions"]);
|
||||||
|
|
||||||
const persistOptions = {
|
const persistOptions = {
|
||||||
whitelist: ["claims"],
|
whitelist: ["claims", "subscriptions"],
|
||||||
// Order is important. Needs to be compressed last or other transforms can't
|
// Order is important. Needs to be compressed last or other transforms can't
|
||||||
// read the data
|
// read the data
|
||||||
transforms: [saveClaimsFilter, compressor],
|
transforms: [saveClaimsFilter, subscriptionsFilter, compressor],
|
||||||
debounce: 10000,
|
debounce: 10000,
|
||||||
storage: localForage,
|
storage: localForage,
|
||||||
};
|
};
|
||||||
window.cacheStore = persistStore(reduxStore, persistOptions);
|
|
||||||
|
|
||||||
export default reduxStore;
|
window.cacheStore = persistStore(store, persistOptions, err => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Unable to load saved settings");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default store;
|
||||||
|
|
|
@ -1,11 +1,17 @@
|
||||||
// util for creating reducers
|
// util for creating reducers
|
||||||
// based off of redux-actions
|
// based off of redux-actions
|
||||||
// https://redux-actions.js.org/docs/api/handleAction.html#handleactions
|
// https://redux-actions.js.org/docs/api/handleAction.html#handleactions
|
||||||
|
|
||||||
export const handleActions = (actionMap, defaultState) => {
|
export const handleActions = (actionMap, defaultState) => {
|
||||||
return (state = defaultState, action) => {
|
return (state = defaultState, action) => {
|
||||||
const handler = actionMap[action.type];
|
const handler = actionMap[action.type];
|
||||||
const newState = handler ? handler(state, action) : {};
|
|
||||||
|
if (handler) {
|
||||||
|
const newState = handler(state, action);
|
||||||
return Object.assign({}, state, newState);
|
return Object.assign({}, state, newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// just return the original state if no handler
|
||||||
|
// returning a copy here breaks redux-persist
|
||||||
|
return state;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue