Add channel subscriptions count below author. #6867

Merged
Ruk33 merged 4 commits from 6746-channel-sub-count into master 2021-08-19 17:25:45 +02:00
84 changed files with 2673 additions and 1312 deletions
Showing only changes of commit 49d75c1cef - Show all commits
.env.defaultsCHANGELOG.mdconfig.js
flow-typed
package.json
static
ui
analytics.jscomments.js
component
channelContent
channelEdit
claimListDiscover
comment
commentCreate
commentsList
commentsReplies
common
fileThumbnail
livestreamComment
livestreamComments
notification
publishForm
repostCreate
settingCommentsServer
userChannelFollowIntro
viewers/videoViewer/internal
plugins
videojs-aniview
videojs-recsys
videojs.jsx
walletFiatAccountHistory
walletFiatBalance
walletFiatPaymentBalance
walletFiatPaymentHistory
walletSendTip
walletTipAmountSelector
constants
effects
modal/modalRemoveCard
page
embedWrapper
file
livestreamCurrent
search
settings
settingsAdvanced
settingsCreator
settingsStripeAccount
settingsStripeCard
show
wallet
redux
scss/component
util
web

View file

@ -12,13 +12,14 @@ LBRY_WEB_API=https://api.na-backend.odysee.com
LBRY_WEB_STREAMING_API=https://cdn.lbryplayer.xyz
LBRY_WEB_BUFFER_API=https://collector-service.api.lbry.tv/api/v1/events/video
COMMENT_SERVER_API=https://comments.odysee.com/api/v2
COMMENT_SERVER_NAME=Odysee
SEARCH_SERVER_API=https://lighthouse.odysee.com/search
SOCKETY_SERVER_API=wss://sockety.odysee.com/ws
THUMBNAIL_CDN_URL=https://image-processor.vanwanet.com/optimize/
WELCOME_VERSION=1.0
# STRIPE
STRIPE_PUBLIC_KEY='pk_test_NoL1JWL7i1ipfhVId5KfDZgo'
# STRIPE_PUBLIC_KEY='pk_test_NoL1JWL7i1ipfhVId5KfDZgo'
# Analytics
MATOMO_URL=https://analytics.lbry.com/
@ -51,6 +52,7 @@ YRBL_SAD_IMG_URL=https://cdn.lbryplayer.xyz/api/v3/streams/free/yrbl-sad/c2d9649
#LOGO_TEXT_LIGHT=https://cdn.lbryplayer.xyz/api/v3/streams/free/yrbl-sad/c2d9649633d974e5ffb503925e1f17d951f1bd0f/f262dd
#LOGO_TEXT_DARK=https://cdn.lbryplayer.xyz/api/v3/streams/free/yrbl-sad/c2d9649633d974e5ffb503925e1f17d951f1bd0f/f262dd
#AVATAR_DEFAULT=
#MISSING_THUMB_DEFAULT=
#FAVICON=
# LOCALE

View file

@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Update lighthouse search api _community pr!_ ([#6731](https://github.com/lbryio/lbry-desktop/pull/6731))
- Update sockety api _community pr!_ ([#6747](https://github.com/lbryio/lbry-desktop/pull/6747))
- Use resolve for OG metadata instead of chainquery _community pr!_ ([#6787](https://github.com/lbryio/lbry-desktop/pull/6787))
- Improved clickability of notification links _community pr!_ ([#6711](https://github.com/lbryio/lbry-desktop/pull/6711))
### Fixed
- App now supports '#' and ':' for claimId separator ([#6496](https://github.com/lbryio/lbry-desktop/pull/6496))
@ -36,6 +37,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Don't break words in chat + fix text overflow past 3 dot menu _community pr!_ ([#6602](https://github.com/lbryio/lbry-desktop/pull/6602))
- Fix embed shows wrong OG metadata _community pr!_ ([#6815](https://github.com/lbryio/lbry-desktop/pull/6815))
- Fix OG: "Unparsable data structure - Truncated Unicode character" _community pr!_ ([#6839](https://github.com/lbryio/lbry-desktop/pull/6839))
- Fix Paid embed warning overlay redirection button now links to odysee _community pr!_ ([#6819](https://github.com/lbryio/lbry-desktop/pull/6819))
- Fix comment section redirection to create channel _community pr!_ ([#6557](https://github.com/lbryio/lbry-desktop/pull/6557))
## [0.51.1] - [2021-06-26]

View file

@ -15,6 +15,7 @@ const config = {
LBRY_WEB_BUFFER_API: process.env.LBRY_WEB_BUFFER_API,
SEARCH_SERVER_API: process.env.SEARCH_SERVER_API,
COMMENT_SERVER_API: process.env.COMMENT_SERVER_API,
COMMENT_SERVER_NAME: process.env.COMMENT_SERVER_NAME,
SOCKETY_SERVER_API: process.env.SOCKETY_SERVER_API,
WELCOME_VERSION: process.env.WELCOME_VERSION,
DOMAIN: process.env.DOMAIN,
@ -34,6 +35,7 @@ const config = {
LOGO_TEXT_LIGHT: process.env.LOGO_TEXT_LIGHT,
LOGO_TEXT_DARK: process.env.LOGO_TEXT_DARK,
AVATAR_DEFAULT: process.env.AVATAR_DEFAULT,
MISSING_THUMB_DEFAULT: process.env.MISSING_THUMB_DEFAULT,
// OG
OG_TITLE_SUFFIX: process.env.OG_TITLE_SUFFIX,
OG_HOMEPAGE_TITLE: process.env.OG_HOMEPAGE_TITLE,

21
flow-typed/Comment.js vendored
View file

@ -14,6 +14,9 @@ declare type Comment = {
is_pinned: boolean,
support_amount: number,
replies: number, // number of direct replies (i.e. excluding nested replies).
is_moderator: boolean,
is_creator: boolean,
is_global_mod: boolean,
is_fiat?: boolean,
};
@ -38,6 +41,7 @@ declare type CommentsState = {
topLevelTotalCommentsById: { [string]: number }, // ClaimID -> total top level comments in commentron.
commentById: { [string]: Comment },
linkedCommentAncestors: { [string]: Array<string> }, // {"linkedCommentId": ["parentId", "grandParentId", ...]}
pinnedCommentsById: {}, // ClaimId -> array of pinned comment IDs
isLoading: boolean,
isLoadingByParentId: { [string]: boolean },
myComments: ?Set<string>,
@ -57,7 +61,6 @@ declare type CommentsState = {
blockingByUri: {},
unBlockingByUri: {},
togglingForDelegatorMap: {[string]: Array<string>}, // {"blockedUri": ["delegatorUri1", ""delegatorUri2", ...]}
commentsDisabledChannelIds: Array<string>,
settingsByChannelId: { [string]: PerChannelSettings }, // ChannelID -> settings
fetchingSettings: boolean,
fetchingBlockedWords: boolean,
@ -221,10 +224,20 @@ declare type ModerationAmIParams = {
};
declare type SettingsParams = {
channel_name: string,
channel_name?: string,
channel_id: string,
signature: string,
signing_ts: string,
signature?: string,
signing_ts?: string,
};
declare type SettingsResponse = {
words?: string,
comments_enabled: boolean,
min_tip_amount_comment: number,
min_tip_amount_super_chat: number,
slow_mode_min_gap: number,
curse_jar_amount: number,
filters_enabled?: boolean,
};
declare type UpdateSettingsParams = {

View file

@ -28,7 +28,7 @@ declare type SearchOptions = {
declare type SearchState = {
options: SearchOptions,
urisByQuery: {},
resultsByQuery: {},
hasReachedMaxResultsLength: {},
searching: boolean,
};
@ -40,6 +40,7 @@ declare type SearchSuccess = {
from: number,
size: number,
uris: Array<string>,
recsys: string,
},
};

View file

@ -1,6 +1,6 @@
{
"name": "lbry",
"version": "0.51.2-rc.1",
"version": "0.51.2-rc.4",
"description": "A browser for the LBRY network, a digital marketplace controlled by its users.",
"keywords": [
"lbry"
@ -38,7 +38,7 @@
"build:dir": "yarn build -- --dir -c.compression=store -c.mac.identity=null",
"crossenv": "./node_modules/cross-env/dist/bin/cross-env",
"lint": "eslint 'ui/**/*.{js,jsx}' && eslint 'web/**/*.{js,jsx}' && eslint 'electron/**/*.js' && flow",
"lint-fix": "eslint --fix 'ui/**/*.{js,jsx}' && eslint --fix 'web/**/*.{js,jsx}' && eslint --fix 'electron/**/*.js' && flow",
"lint-fix": "eslint --fix --quiet 'ui/**/*.{js,jsx}' && eslint --fix --quiet 'web/**/*.{js,jsx}' && eslint --fix --quiet 'electron/**/*.js'",
"format": "prettier 'src/**/*.{js,jsx,scss,json}' --write",
"flow-defs": "flow-typed install",
"precommit": "lint-staged",

View file

@ -1729,6 +1729,7 @@
"Are you sure you want to view this content? Viewing will not unmute @%channel%": "Are you sure you want to view this content? Viewing will not unmute @%channel%",
"View Content": "View Content",
"Global": "Global",
"Global Admin": "Global Admin",
"Moderator": "Moderator",
"Global Unblock Channel": "Global Unblock Channel",
"Global Block Channel": "Global Block Channel",
@ -2038,6 +2039,13 @@
"Your publish is being confirmed and will be live soon": "Your publish is being confirmed and will be live soon",
"Clear Edits": "Clear Edits",
"Something not quite right..": "Something not quite right..",
"Comments server": "Comments server",
"Default comments server (%name%)": "Default comments server (%name%)",
"Custom comments server": "Custom comments server",
"Failed to fetch comments.": "Failed to fetch comments.",
"Failed to fetch comments. Verify custom server settings.": "Failed to fetch comments. Verify custom server settings.",
"Commenting server is not set.": "Commenting server is not set.",
"Comments are not currently enabled.": "Comments are not currently enabled.",
"See All": "See All",
"Supporting content requires %lbc%": "Supporting content requires %lbc%",
"With %lbc%, you can send tips to your favorite creators, or help boost their content for more people to see.": "With %lbc%, you can send tips to your favorite creators, or help boost their content for more people to see.",
@ -2084,5 +2092,8 @@
"Load More": "Load More",
"%channelSubCount% Followers": "%channelSubCount% Followers",
"%channelSubCount% Follower": "%channelSubCount% Follower",
"Collection": "Collection",
"%channelName% isn't live right now, but the chat is! Check back later to watch the stream.": "%channelName% isn't live right now, but the chat is! Check back later to watch the stream.",
"Review": "Review",
"--end--": "--end--"
}

View file

@ -87,16 +87,20 @@ if (window.localStorage.getItem(SHARE_INTERNAL) === 'true') internalAnalyticsEna
* @returns {String}
*/
function getDeviceType() {
var userAgent = navigator.userAgent || navigator.vendor || window.opera;
if (/android/i.test(userAgent)) {
return 'and';
}
// iOS detection from: http://stackoverflow.com/a/9039885/177710
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
return 'ios';
}
// We may not care what the device is if it's in a web browser. Commenting out for now.
// if (!IS_WEB) {
// return 'elt';
// }
// const userAgent = navigator.userAgent || navigator.vendor || window.opera;
//
// if (/android/i.test(userAgent)) {
// return 'adr';
// }
//
// // iOS detection from: http://stackoverflow.com/a/9039885/177710
// if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
// return 'ios';
// }
// default as web, this can be optimized
return 'web';
@ -165,7 +169,7 @@ function startWatchmanIntervalIfNotRunning() {
lastSentTime = new Date();
// only set an interval if analytics are enabled and is prod
if (internalAnalyticsEnabled && isProduction) {
if (isProduction && IS_WEB) {
watchmanInterval = setInterval(sendAndResetWatchmanData, 1000 * SEND_DATA_TO_WATCHMAN_INTERVAL);
}
}

View file

@ -4,6 +4,13 @@ import { COMMENT_SERVER_API } from 'config';
const Comments = {
url: COMMENT_SERVER_API,
enabled: Boolean(COMMENT_SERVER_API),
isCustomServer: false,
setServerUrl: (customUrl: ?string) => {
Comments.url = customUrl === undefined ? COMMENT_SERVER_API : customUrl;
Comments.enabled = Boolean(Comments.url);
Comments.isCustomServer = Comments.url !== COMMENT_SERVER_API;
},
moderation_block: (params: ModerationBlockParams) => fetchCommentsApi('moderation.Block', params),
moderation_unblock: (params: ModerationBlockParams) => fetchCommentsApi('moderation.UnBlock', params),
@ -27,12 +34,15 @@ const Comments = {
setting_unblock_word: (params: BlockWordParams) => fetchCommentsApi('setting.UnBlockWord', params),
setting_list_blocked_words: (params: SettingsParams) => fetchCommentsApi('setting.ListBlockedWords', params),
setting_update: (params: UpdateSettingsParams) => fetchCommentsApi('setting.Update', params),
setting_get: (params: SettingsParams) => fetchCommentsApi('setting.Get', params),
super_list: (params: SuperListParams) => fetchCommentsApi('comment.SuperChatList', params),
};
function fetchCommentsApi(method: string, params: {}) {
if (!Comments.enabled) {
return Promise.reject('Comments are not currently enabled'); // eslint-disable-line
if (!Comments.url) {
return Promise.reject(new Error('Commenting server is not set.'));
} else if (!Comments.enabled) {
return Promise.reject('Comments are not currently enabled.'); // eslint-disable-line
}
const url = `${Comments.url}?m=${method}`;

View file

@ -81,7 +81,7 @@ function ChannelContent(props: Props) {
!showMature ? '&nsfw=false&size=50&from=0' : ''
}`
)
.then((results) => {
.then(({ body: results }) => {
const urls = results.map(({ name, claimId }) => {
return `lbry://${name}#${claimId}`;
});

View file

@ -17,6 +17,8 @@ import {
} from 'lbry-redux';
import { doOpenModal } from 'redux/actions/app';
import { doUpdateBlockListForPublishedChannel } from 'redux/actions/comments';
import { doClaimInitialRewards } from 'redux/actions/rewards';
import { selectIsClaimingInitialRewards, selectHasClaimedInitialRewards } from 'redux/selectors/rewards';
import ChannelForm from './view';
const select = (state, props) => ({
@ -36,6 +38,8 @@ const select = (state, props) => ({
createError: selectCreateChannelError(state),
creatingChannel: selectCreatingChannel(state),
balance: selectBalance(state),
isClaimingInitialRewards: selectIsClaimingInitialRewards(state),
hasClaimedInitialRewards: selectHasClaimedInitialRewards(state),
});
const perform = (dispatch) => ({
@ -50,6 +54,7 @@ const perform = (dispatch) => ({
);
},
clearChannelErrors: () => dispatch(doClearChannelErrors()),
claimInitialRewards: () => dispatch(doClaimInitialRewards()),
});
export default connect(select, perform)(ChannelForm);

View file

@ -48,6 +48,7 @@ type Props = {
createError: string,
creatingChannel: boolean,
clearChannelErrors: () => void,
claimInitialRewards: () => void,
onDone: () => void,
openModal: (
id: string,
@ -55,6 +56,8 @@ type Props = {
) => void,
uri: string,
disabled: boolean,
isClaimingInitialRewards: boolean,
hasClaimedInitialRewards: boolean,
};
function ChannelForm(props: Props) {
@ -79,8 +82,11 @@ function ChannelForm(props: Props) {
creatingChannel,
createError,
clearChannelErrors,
claimInitialRewards,
openModal,
disabled,
isClaimingInitialRewards,
hasClaimedInitialRewards,
} = props;
const [nameError, setNameError] = React.useState(undefined);
const [bidError, setBidError] = React.useState('');
@ -94,6 +100,22 @@ function ChannelForm(props: Props) {
const languageParam = params.languages;
const primaryLanguage = Array.isArray(languageParam) && languageParam.length && languageParam[0];
const secondaryLanguage = Array.isArray(languageParam) && languageParam.length >= 2 && languageParam[1];
const submitLabel = React.useMemo(() => {
if (isClaimingInitialRewards) {
return __('Claiming credits...');
}
return creatingChannel || updatingChannel ? __('Submitting') : __('Submit');
}, [isClaimingInitialRewards, creatingChannel, updatingChannel]);
const submitDisabled = React.useMemo(() => {
return (
isClaimingInitialRewards ||
creatingChannel ||
updatingChannel ||
nameError ||
bidError ||
(isNewChannel && !params.name)
);
}, [isClaimingInitialRewards, creatingChannel, updatingChannel, nameError, bidError, isNewChannel, params]);
function getChannelParams() {
// fill this in with sdk data
@ -219,6 +241,12 @@ function ChannelForm(props: Props) {
clearChannelErrors();
}, [clearChannelErrors]);
React.useEffect(() => {
if (!hasClaimedInitialRewards) {
claimInitialRewards();
}
}, [hasClaimedInitialRewards, claimInitialRewards]);
// TODO clear and bail after submit
return (
<>
@ -453,14 +481,7 @@ function ChannelForm(props: Props) {
actions={
<>
<div className="section__actions">
<Button
button="primary"
disabled={
creatingChannel || updatingChannel || nameError || bidError || (isNewChannel && !params.name)
}
label={creatingChannel || updatingChannel ? __('Submitting') : __('Submit')}
onClick={handleSubmit}
/>
<Button button="primary" disabled={submitDisabled} label={submitLabel} onClick={handleSubmit} />
<Button button="link" label={__('Cancel')} onClick={onDone} />
</div>
{errorMsg ? (

View file

@ -141,7 +141,6 @@ function ClaimListDiscover(props: Props) {
const { search } = location;
const [page, setPage] = React.useState(1);
const [forceRefresh, setForceRefresh] = React.useState();
const [finalUris, setFinalUris] = React.useState([]);
const isLargeScreen = useIsLargeScreen();
const [orderParamEntry, setOrderParamEntry] = usePersistedState(`entry-${location.pathname}`, CS.ORDER_BY_TRENDING);
const [orderParamUser, setOrderParamUser] = usePersistedState(`orderUser-${location.pathname}`, CS.ORDER_BY_TRENDING);
@ -387,6 +386,9 @@ function ClaimListDiscover(props: Props) {
: undefined;
const livestreamSearchResult = livestreamSearchKey && claimSearchByQuery[livestreamSearchKey];
const [finalUris, setFinalUris] = React.useState(
getFinalUrisInitialState(history.action === 'POP', claimSearchResult)
);
const [prevOptions, setPrevOptions] = React.useState(null);
if (!isJustScrollingToNewPage(prevOptions, options)) {
@ -448,6 +450,10 @@ function ClaimListDiscover(props: Props) {
</div>
);
// **************************************************************************
// Helpers
// **************************************************************************
// Returns true if the change in 'options' indicate that we are simply scrolling
// down to a new page; false otherwise.
function isJustScrollingToNewPage(prevOptions, options) {
@ -500,6 +506,17 @@ function ClaimListDiscover(props: Props) {
return prev.length === next.length && prev.every((value, index) => value === next[index]);
}
function getFinalUrisInitialState(isNavigatingBack, claimSearchResult) {
if (isNavigatingBack && claimSearchResult && claimSearchResult.length > 0) {
return claimSearchResult;
} else {
return [];
}
}
// **************************************************************************
// **************************************************************************
React.useEffect(() => {
if (shouldPerformSearch) {
const searchOptions = JSON.parse(optionsStringForEffect);

View file

@ -10,6 +10,7 @@ import DateTime from 'component/dateTime';
import Button from 'component/button';
import Expandable from 'component/expandable';
import MarkdownPreview from 'component/common/markdown-preview';
import Tooltip from 'component/common/tooltip';
import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuButton } from '@reach/menu-button';
import Icon from 'component/common/icon';
@ -59,7 +60,9 @@ type Props = {
stakedLevel: number,
supportAmount: number,
numDirectReplies: number,
isFiat: boolean
isModerator: boolean,
isGlobalMod: boolean,
isFiat: boolean,
};
const LENGTH_TO_COLLAPSE = 300;
@ -92,6 +95,8 @@ function Comment(props: Props) {
stakedLevel,
supportAmount,
numDirectReplies,
isModerator,
isGlobalMod,
isFiat,
} = props;
@ -225,6 +230,22 @@ function Comment(props: Props) {
<div className="comment__body-container">
<div className="comment__meta">
<div className="comment__meta-information">
{isGlobalMod && (
<Tooltip label={__('Admin')}>
<span className="comment__badge comment__badge--global-mod">
<Icon icon={ICONS.BADGE_MOD} size={20} />
</span>
</Tooltip>
)}
{isModerator && (
<Tooltip label={__('Moderator')}>
<span className="comment__badge comment__badge--mod">
<Icon icon={ICONS.BADGE_MOD} size={20} />
</span>
</Tooltip>
)}
{!author ? (
<span className="comment__author">{__('Anonymous')}</span>
) : (

View file

@ -7,30 +7,42 @@ import {
doSendTip,
} from 'lbry-redux';
import { doOpenModal, doSetActiveChannel } from 'redux/actions/app';
import { doCommentCreate } from 'redux/actions/comments';
import { doCommentCreate, doFetchCreatorSettings } from 'redux/actions/comments';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectActiveChannelClaim } from 'redux/selectors/app';
import { makeSelectCommentsDisabledForUri } from 'redux/selectors/comments';
import { selectSettingsByChannelId } from 'redux/selectors/comments';
import { CommentCreate } from './view';
import { doToast } from 'redux/actions/notifications';
const select = (state, props) => ({
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
commentsDisabledBySettings: makeSelectCommentsDisabledForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
channels: selectMyChannelClaims(state),
isFetchingChannels: selectFetchingMyChannels(state),
activeChannelClaim: selectActiveChannelClaim(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
settingsByChannelId: selectSettingsByChannelId(state),
});
const perform = (dispatch, ownProps) => ({
createComment: (comment, claimId, parentId, txid, payment_intent_id, environment) =>
dispatch(doCommentCreate(comment, claimId, parentId, ownProps.uri, ownProps.livestream, txid, payment_intent_id, environment)),
dispatch(
doCommentCreate(
comment,
claimId,
parentId,
ownProps.uri,
ownProps.livestream,
txid,
payment_intent_id,
environment
)
),
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
setActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)),
sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)),
doToast: (options) => dispatch(doToast(options)),
doFetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)),
});
export default connect(select, perform)(CommentCreate);

View file

@ -1,11 +1,12 @@
// @flow
import type { ElementRef } from 'react';
import { SIMPLE_SITE, STRIPE_PUBLIC_KEY } from 'config';
import { SIMPLE_SITE } from 'config';
import * as PAGES from 'constants/pages';
import * as ICONS from 'constants/icons';
import React from 'react';
import classnames from 'classnames';
import { FormField, Form } from 'component/common/form';
import Icon from 'component/common/icon';
import Button from 'component/button';
import SelectChannel from 'component/selectChannel';
import usePersistedState from 'effects/use-persisted-state';
@ -14,16 +15,14 @@ import { useHistory } from 'react-router';
import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import CreditAmount from 'component/common/credit-amount';
import ChannelThumbnail from 'component/channelThumbnail';
import I18nMessage from 'component/i18nMessage';
import UriIndicator from 'component/uriIndicator';
import Empty from 'component/common/empty';
import { getChannelIdFromClaim } from 'util/claim';
import { Lbryio } from 'lbryinc';
let stripeEnvironment = 'test';
// if the key contains pk_live it's a live key
// update the environment for the calls to the backend to indicate which environment to hit
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
stripeEnvironment = 'live';
}
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
const TAB_FIAT = 'TabFiat';
const TAB_LBC = 'TabLBC';
@ -32,7 +31,6 @@ type Props = {
uri: string,
claim: StreamClaim,
createComment: (string, string, string, ?string, ?string, ?string) => Promise<any>,
commentsDisabledBySettings: boolean,
channels: ?Array<ChannelClaim>,
onDoneReplying?: () => void,
onCancelReplying?: () => void,
@ -49,13 +47,13 @@ type Props = {
claimIsMine: boolean,
sendTip: ({}, (any) => void, (any) => void) => void,
doToast: ({ message: string }) => void,
disabled: boolean,
doFetchCreatorSettings: (channelId: string) => Promise<any>,
settingsByChannelId: { [channelId: string]: PerChannelSettings },
};
export function CommentCreate(props: Props) {
const {
createComment,
commentsDisabledBySettings,
claim,
channels,
onDoneReplying,
@ -71,6 +69,8 @@ export function CommentCreate(props: Props) {
claimIsMine,
sendTip,
doToast,
doFetchCreatorSettings,
settingsByChannelId,
} = props;
const buttonRef: ElementRef<any> = React.useRef();
const {
@ -90,8 +90,42 @@ export function CommentCreate(props: Props) {
const charCount = commentValue.length;
const [activeTab, setActiveTab] = React.useState('');
const [tipError, setTipError] = React.useState();
const disabled = isSubmitting || !activeChannelClaim || !commentValue.length;
const disabled = isSubmitting || isFetchingChannels || !commentValue.length;
const [shouldDisableReviewButton, setShouldDisableReviewButton] = React.useState();
const channelId = getChannelIdFromClaim(claim);
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
const minSuper = (channelSettings && channelSettings.min_tip_amount_super_chat) || 0;
const minTip = (channelSettings && channelSettings.min_tip_amount_comment) || 0;
const minAmount = minTip || minSuper || 0;
const minAmountMet = minAmount === 0 || tipAmount >= minAmount;
const minAmountRef = React.useRef(minAmount);
minAmountRef.current = minAmount;
const MinAmountNotice = minAmount ? (
<div className="help--notice comment--min-amount-notice">
<I18nMessage tokens={{ lbc: <CreditAmount noFormat amount={minAmount} /> }}>
{minTip ? 'Comment min: %lbc%' : minSuper ? 'HyperChat min: %lbc%' : ''}
</I18nMessage>
<Icon
customTooltipText={
minTip
? __('This channel requires a minimum tip for each comment.')
: minSuper
? __('This channel requires a minimum amount for HyperChats to be visible.')
: ''
}
className="icon--help"
icon={ICONS.HELP}
tooltip
size={16}
/>
</div>
) : null;
// **************************************************************************
// Functions
// **************************************************************************
function handleCommentChange(event) {
let commentValue;
@ -131,6 +165,15 @@ export function CommentCreate(props: Props) {
return;
}
if (!channelId) {
doToast({
message: __('Unable to verify channel settings. Try refreshing the page.'),
isError: true,
});
return;
}
// if comment post didn't work, but tip was already made, try again to create comment
if (commentFailure && tipAmount === successTip.tipAmount) {
handleCreateComment(successTip.txid);
return;
@ -138,6 +181,29 @@ export function CommentCreate(props: Props) {
setSuccessTip({ txid: undefined, tipAmount: undefined });
}
// !! Beware of stale closure when editing the then-block, including doSubmitTip().
doFetchCreatorSettings(channelId).then(() => {
const lockedMinAmount = minAmount; // value during closure.
const currentMinAmount = minAmountRef.current; // value from latest doFetchCreatorSettings().
if (lockedMinAmount !== currentMinAmount) {
doToast({
message: __('The creator just updated the minimum setting. Please revise or double-check your tip amount.'),
isError: true,
});
setIsReviewingSupportComment(false);
return;
}
doSubmitTip();
});
}
function doSubmitTip() {
if (!activeChannelClaim) {
return;
}
const params = {
amount: tipAmount,
claim_id: claimId,
@ -147,6 +213,19 @@ export function CommentCreate(props: Props) {
const activeChannelName = activeChannelClaim && activeChannelClaim.name;
const activeChannelId = activeChannelClaim && activeChannelClaim.claim_id;
// setup variables for tip API
let channelClaimId, tipChannelName;
// if there is a signing channel it's on a file
if (claim.signing_channel) {
channelClaimId = claim.signing_channel.claim_id;
tipChannelName = claim.signing_channel.name;
// otherwise it's on the channel page
} else {
channelClaimId = claim.claim_id;
tipChannelName = claim.name;
}
setIsSubmitting(true);
if (activeTab === TAB_LBC) {
@ -160,6 +239,17 @@ export function CommentCreate(props: Props) {
setTimeout(() => {
handleCreateComment(txid);
}, 1500);
doToast({
message: __(
"You sent %tipAmount% LBRY Credits as a tip to %tipChannelName%, I'm sure they appreciate it!",
{
tipAmount: tipAmount, // force show decimal places
tipChannelName,
}
),
});
setSuccessTip({ txid, tipAmount });
},
() => {
@ -168,19 +258,6 @@ export function CommentCreate(props: Props) {
}
);
} else {
// setup variables for tip API
let channelClaimId, tipChannelName;
// if there is a signing channel it's on a file
if (claim.signing_channel) {
channelClaimId = claim.signing_channel.claim_id;
tipChannelName = claim.signing_channel.name;
// otherwise it's on the channel page
} else {
channelClaimId = claim.claim_id;
tipChannelName = claim.name;
}
const sourceClaimId = claim.claim_id;
const roundedAmount = Math.round(tipAmount * 100) / 100;
@ -188,7 +265,8 @@ export function CommentCreate(props: Props) {
'customer',
'tip',
{
amount: 100 * roundedAmount, // convert from dollars to cents
// round to deal with floating point precision
amount: Math.round(100 * roundedAmount), // convert from dollars to cents
creator_channel_name: tipChannelName, // creator_channel_name
creator_channel_claim_id: channelClaimId,
tipper_channel_name: activeChannelName,
@ -259,6 +337,12 @@ export function CommentCreate(props: Props) {
.catch(() => {
setIsSubmitting(false);
setCommentFailure(true);
if (channelId) {
// It could be that the creator added a minimum tip setting.
// Manually update for now until a websocket msg is available.
doFetchCreatorSettings(channelId);
}
});
}
@ -266,11 +350,26 @@ export function CommentCreate(props: Props) {
setAdvancedEditor(!advancedEditor);
}
if (commentsDisabledBySettings) {
// **************************************************************************
// Effects
// **************************************************************************
// Fetch channel constraints if not already.
React.useEffect(() => {
if (!channelSettings && channelId) {
doFetchCreatorSettings(channelId);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// **************************************************************************
// Render
// **************************************************************************
if (channelSettings && !channelSettings.comments_enabled) {
return <Empty padded text={__('This channel has disabled comments on their page.')} />;
}
if (!hasChannels) {
if (!isFetchingChannels && !hasChannels) {
return (
<div
role="button"
@ -288,12 +387,7 @@ export function CommentCreate(props: Props) {
}
}}
>
<FormField
type="textarea"
name={'comment_signup_prompt'}
placeholder={__('Say something about this...')}
label={isFetchingChannels ? __('Comment') : undefined}
/>
<FormField type="textarea" name={'comment_signup_prompt'} placeholder={__('Say something about this...')} />
<div className="section__actions--no-margin">
<Button disabled button="primary" label={__('Post --[button to submit something]--')} requiresAuth={IS_WEB} />
</div>
@ -322,7 +416,7 @@ export function CommentCreate(props: Props) {
<Button
autoFocus
button="primary"
disabled={disabled}
disabled={disabled || !minAmountMet}
label={
isSubmitting
? __('Sending...')
@ -338,6 +432,7 @@ export function CommentCreate(props: Props) {
label={__('Cancel')}
onClick={() => setIsReviewingSupportComment(false)}
/>
{MinAmountNotice}
</div>
</div>
);
@ -353,7 +448,7 @@ export function CommentCreate(props: Props) {
})}
>
<FormField
disabled={!activeChannelClaim}
disabled={isFetchingChannels}
type={SIMPLE_SITE ? 'textarea' : advancedEditor && !isReply ? 'markdown' : 'textarea'}
name={isReply ? 'content_reply' : 'content_description'}
label={
@ -391,34 +486,37 @@ export function CommentCreate(props: Props) {
{isSupportComment ? (
<>
<Button
disabled={disabled || tipError || shouldDisableReviewButton}
disabled={disabled || tipError || shouldDisableReviewButton || !minAmountMet}
type="button"
button="primary"
icon={activeTab === TAB_LBC ? ICONS.LBC : ICONS.FINANCE}
label={__('Review')}
onClick={() => setIsReviewingSupportComment(true)}
requiresAuth={IS_WEB}
/>
<Button disabled={disabled} button="link" label={__('Cancel')} onClick={() => setIsSupportComment(false)} />
</>
) : (
<>
<Button
ref={buttonRef}
button="primary"
disabled={disabled}
type="submit"
label={
isReply
? isSubmitting
? __('Replying...')
: __('Reply')
: isSubmitting
? __('Commenting...')
: __('Comment --[button to submit something]--')
}
requiresAuth={IS_WEB}
/>
{(!minTip || claimIsMine) && (
<Button
ref={buttonRef}
button="primary"
disabled={disabled}
type="submit"
label={
isReply
? isSubmitting
? __('Replying...')
: __('Reply')
: isSubmitting
? __('Commenting...')
: __('Comment --[button to submit something]--')
}
requiresAuth={IS_WEB}
/>
)}
{!claimIsMine && (
<Button
disabled={disabled}
@ -431,7 +529,8 @@ export function CommentCreate(props: Props) {
}}
/>
)}
{!claimIsMine && (
{/* @if TARGET='web' */}
{!claimIsMine && stripeEnvironment && (
<Button
disabled={disabled}
button="alt"
@ -443,7 +542,8 @@ export function CommentCreate(props: Props) {
}}
/>
)}
{isReply && (
{/* @endif */}
{isReply && !minTip && (
<Button
button="link"
label={__('Cancel')}
@ -456,6 +556,7 @@ export function CommentCreate(props: Props) {
)}
</>
)}
{MinAmountNotice}
</div>
</Form>
);

View file

@ -1,5 +1,10 @@
import { connect } from 'react-redux';
import { makeSelectClaimIsMine, selectFetchingMyChannels, selectMyChannelClaims } from 'lbry-redux';
import {
makeSelectClaimForUri,
makeSelectClaimIsMine,
selectFetchingMyChannels,
selectMyChannelClaims,
} from 'lbry-redux';
import {
makeSelectTopLevelCommentsForUri,
makeSelectTopLevelTotalPagesForUri,
@ -7,12 +12,12 @@ import {
selectIsFetchingReacts,
makeSelectTotalCommentsCountForUri,
selectOthersReactsById,
makeSelectCommentsDisabledForUri,
selectMyReactionsByCommentId,
makeSelectCommentIdsForUri,
selectSettingsByChannelId,
makeSelectPinnedCommentsForUri,
} from 'redux/selectors/comments';
import { doCommentReset, doCommentList, doCommentById, doCommentReactList } from 'redux/actions/comments';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { selectActiveChannelClaim } from 'redux/selectors/app';
import CommentsList from './view';
@ -21,15 +26,16 @@ const select = (state, props) => {
return {
myChannels: selectMyChannelClaims(state),
allCommentIds: makeSelectCommentIdsForUri(props.uri)(state),
pinnedComments: makeSelectPinnedCommentsForUri(props.uri)(state),
topLevelComments: makeSelectTopLevelCommentsForUri(props.uri)(state),
topLevelTotalPages: makeSelectTopLevelTotalPagesForUri(props.uri)(state),
totalComments: makeSelectTotalCommentsCountForUri(props.uri)(state),
claim: makeSelectClaimForUri(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
isFetchingComments: selectIsFetchingComments(state),
isFetchingReacts: selectIsFetchingReacts(state),
commentingEnabled: IS_WEB ? Boolean(selectUserVerifiedEmail(state)) : true,
commentsDisabledBySettings: makeSelectCommentsDisabledForUri(props.uri)(state),
fetchingChannels: selectFetchingMyChannels(state),
settingsByChannelId: selectSettingsByChannelId(state),
myReactsByCommentId: selectMyReactionsByCommentId(state),
othersReactsById: selectOthersReactsById(state),
activeChannelId: activeChannelClaim && activeChannelClaim.claim_id,

View file

@ -14,6 +14,7 @@ import { ENABLE_COMMENT_REACTIONS } from 'config';
import Empty from 'component/common/empty';
import debounce from 'util/debounce';
import { useIsMobile } from 'effects/use-screensize';
import { getChannelIdFromClaim } from 'util/claim';
const DEBOUNCE_SCROLL_HANDLER_MS = 200;
@ -27,14 +28,15 @@ function scaleToDevicePixelRatio(value) {
type Props = {
allCommentIds: any,
pinnedComments: Array<Comment>,
topLevelComments: Array<Comment>,
topLevelTotalPages: number,
commentsDisabledBySettings: boolean,
fetchTopLevelComments: (string, number, number, number) => void,
fetchComment: (string) => void,
fetchReacts: (Array<string>) => Promise<any>,
resetComments: (string) => void,
uri: string,
claim: ?Claim,
claimIsMine: boolean,
myChannels: ?Array<ChannelClaim>,
isFetchingComments: boolean,
@ -45,6 +47,7 @@ type Props = {
myReactsByCommentId: ?{ [string]: Array<string> }, // "CommentId:MyChannelId" -> reaction array (note the ID concatenation)
othersReactsById: ?{ [string]: { [REACTION_TYPES.LIKE | REACTION_TYPES.DISLIKE]: number } },
activeChannelId: ?string,
settingsByChannelId: { [channelId: string]: PerChannelSettings },
};
function CommentList(props: Props) {
@ -55,9 +58,10 @@ function CommentList(props: Props) {
fetchReacts,
resetComments,
uri,
pinnedComments,
topLevelComments,
topLevelTotalPages,
commentsDisabledBySettings,
claim,
claimIsMine,
myChannels,
isFetchingComments,
@ -68,6 +72,7 @@ function CommentList(props: Props) {
myReactsByCommentId,
othersReactsById,
activeChannelId,
settingsByChannelId,
} = props;
const commentRef = React.useRef();
@ -78,6 +83,8 @@ function CommentList(props: Props) {
const isMobile = useIsMobile();
const [expandedComments, setExpandedComments] = React.useState(!isMobile);
const totalFetchedComments = allCommentIds ? allCommentIds.length : 0;
const channelId = getChannelIdFromClaim(claim);
const channelSettings = channelId ? settingsByChannelId[channelId] : undefined;
// Display comments immediately if not fetching reactions
// If not, wait to show comments until reactions are fetched
@ -106,6 +113,34 @@ function CommentList(props: Props) {
}
}
function getCommentElems(comments) {
return comments.map((comment) => {
return (
<CommentView
isTopLevel
threadDepth={3}
key={comment.comment_id}
uri={uri}
authorUri={comment.channel_url}
author={comment.channel_name}
claimId={comment.claim_id}
commentId={comment.comment_id}
message={comment.comment}
timePosted={comment.timestamp * 1000}
claimIsMine={claimIsMine}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
linkedCommentId={linkedCommentId}
isPinned={comment.is_pinned}
supportAmount={comment.support_amount}
numDirectReplies={comment.replies}
isModerator={comment.is_moderator}
isGlobalMod={comment.is_global_mod}
isFiat={comment.is_fiat}
/>
);
});
}
// Reset comments
useEffect(() => {
if (page === 0) {
@ -218,8 +253,6 @@ function CommentList(props: Props) {
topLevelTotalPages,
]);
const displayedComments = readyToDisplayComments ? topLevelComments : [];
return (
<Card
title={
@ -279,7 +312,7 @@ function CommentList(props: Props) {
<>
<CommentCreate uri={uri} />
{!commentsDisabledBySettings && !isFetchingComments && hasNoComments && (
{channelSettings && channelSettings.comments_enabled && !isFetchingComments && hasNoComments && (
<Empty padded text={__('That was pretty deep. What do you think?')} />
)}
@ -290,31 +323,8 @@ function CommentList(props: Props) {
})}
ref={commentRef}
>
{topLevelComments &&
displayedComments &&
displayedComments.map((comment) => {
return (
<CommentView
isTopLevel
threadDepth={3}
key={comment.comment_id}
uri={uri}
authorUri={comment.channel_url}
author={comment.channel_name}
claimId={comment.claim_id}
commentId={comment.comment_id}
message={comment.comment}
timePosted={comment.timestamp * 1000}
claimIsMine={claimIsMine}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
linkedCommentId={linkedCommentId}
isPinned={comment.is_pinned}
supportAmount={comment.support_amount}
numDirectReplies={comment.replies}
isFiat={comment.is_fiat}
/>
);
})}
{readyToDisplayComments && pinnedComments && getCommentElems(pinnedComments)}
{readyToDisplayComments && topLevelComments && getCommentElems(topLevelComments)}
</ul>
{isMobile && (

View file

@ -97,6 +97,8 @@ function CommentsReplies(props: Props) {
commentingEnabled={commentingEnabled}
supportAmount={comment.support_amount}
numDirectReplies={comment.replies}
isModerator={comment.is_moderator}
isGlobalMod={comment.is_global_mod}
/>
);
})}

View file

@ -2331,4 +2331,37 @@ export const icons = {
<path d="M4.954 14.753l3.535 3.535-1.768 1.768-3.535-3.535z" />
</g>
),
[ICONS.BADGE_MOD]: (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{fill:FF3850}.st1{fill:#181021}.st2{fill:#FFFFFF}'}</style>
<g>
<g>
<path
className="st0"
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"
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"
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>
),
};

View file

@ -28,7 +28,7 @@ import { useRect } from '@reach/rect';
// </TabPanels>
// </Tabs>
//
// the base @reach/tabs components handle all the focus/accessibilty labels
// the base @reach/tabs components handle all the focus/accessibility labels
// We're just adding some styling
type TabsProps = {

View file

@ -6,14 +6,16 @@ import useLazyLoading from 'effects/use-lazy-loading';
type Props = {
thumb: string,
fallback: ?string,
children?: Node,
className?: string,
};
const Thumb = (props: Props) => {
const { thumb, children, className } = props;
const { thumb, fallback, children, className } = props;
const thumbnailRef = React.useRef(null);
useLazyLoading(thumbnailRef);
useLazyLoading(thumbnailRef, fallback || '');
return (
<div ref={thumbnailRef} data-background-image={thumb} className={classnames('media__thumb', className)}>

View file

@ -4,6 +4,7 @@ import { getThumbnailCdnUrl } from 'util/thumbnail';
import React from 'react';
import FreezeframeWrapper from './FreezeframeWrapper';
import Placeholder from './placeholder.png';
import { MISSING_THUMB_DEFAULT } from 'config';
import classnames from 'classnames';
import Thumb from './thumb';
@ -42,6 +43,8 @@ function FileThumbnail(props: Props) {
);
}
const fallback = MISSING_THUMB_DEFAULT ? getThumbnailCdnUrl({ thumbnail: MISSING_THUMB_DEFAULT }) : undefined;
let url = thumbnail || (hasResolvedClaim ? Placeholder : '');
// @if TARGET='web'
// Pass image urls through a compression proxy
@ -54,7 +57,7 @@ function FileThumbnail(props: Props) {
if (hasResolvedClaim || thumbnailUrl) {
return (
<Thumb thumb={thumbnailUrl} className={className}>
<Thumb thumb={thumbnailUrl} fallback={fallback} className={className}>
{children}
</Thumb>
);

View file

@ -3,6 +3,7 @@ import * as ICONS from 'constants/icons';
import React from 'react';
import { parseURI } from 'lbry-redux';
import MarkdownPreview from 'component/common/markdown-preview';
import Tooltip from 'component/common/tooltip';
import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuButton } from '@reach/menu-button';
import Icon from 'component/common/icon';
@ -20,11 +21,27 @@ type Props = {
commentIsMine: boolean,
stakedLevel: number,
supportAmount: number,
isModerator: boolean,
isGlobalMod: boolean,
isFiat: boolean,
isPinned: boolean,
};
function LivestreamComment(props: Props) {
const { claim, uri, authorUri, message, commentIsMine, commentId, stakedLevel, supportAmount, isFiat } = props;
const {
claim,
uri,
authorUri,
message,
commentIsMine,
commentId,
stakedLevel,
supportAmount,
isModerator,
isGlobalMod,
isFiat,
isPinned,
} = props;
const [mouseIsHovering, setMouseHover] = React.useState(false);
const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
const { claimName } = parseURI(authorUri);
@ -47,6 +64,22 @@ function LivestreamComment(props: Props) {
<div className="livestream-comment__body">
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />}
<div className="livestream-comment__info">
{isGlobalMod && (
<Tooltip label={__('Admin')}>
<span className="comment__badge comment__badge--global-mod">
<Icon icon={ICONS.BADGE_MOD} size={16} />
</span>
</Tooltip>
)}
{isModerator && (
<Tooltip label={__('Moderator')}>
<span className="comment__badge comment__badge--mod">
<Icon icon={ICONS.BADGE_MOD} size={16} />
</span>
</Tooltip>
)}
<Button
className={classnames('button--uri-indicator comment__author', {
'comment__author--creator': commentByOwnerOfContent,
@ -57,6 +90,13 @@ function LivestreamComment(props: Props) {
{claimName}
</Button>
{isPinned && (
<span className="comment__pin">
<Icon icon={ICONS.PIN} size={14} />
{__('Pinned')}
</span>
)}
<div className="livestream-comment__text">
<MarkdownPreview content={message} promptLinks stakedLevel={stakedLevel} />
</div>
@ -78,6 +118,8 @@ function LivestreamComment(props: Props) {
authorUri={authorUri}
commentIsMine={commentIsMine}
disableEdit
isTopLevel
isPinned={isPinned}
disableRemove={supportAmount > 0}
/>
</Menu>

View file

@ -7,12 +7,14 @@ import {
selectIsFetchingComments,
makeSelectSuperChatsForUri,
makeSelectSuperChatTotalAmountForUri,
makeSelectPinnedCommentsForUri,
} from 'redux/selectors/comments';
import LivestreamComments from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
comments: makeSelectTopLevelCommentsForUri(props.uri)(state).slice(0, 75),
pinnedComments: makeSelectPinnedCommentsForUri(props.uri)(state),
fetchingComments: selectIsFetchingComments(state),
superChats: makeSelectSuperChatsForUri(props.uri)(state),
superChatsTotalAmount: makeSelectSuperChatTotalAmountForUri(props.uri)(state),

View file

@ -19,16 +19,15 @@ type Props = {
doCommentSocketDisconnect: (string) => void,
doCommentList: (string, string, number, number) => void,
comments: Array<Comment>,
pinnedComments: Array<Comment>,
fetchingComments: boolean,
doSuperChatList: (string) => void,
superChats: Array<Comment>,
superChatsTotalAmount: number,
myChannels: ?Array<ChannelClaim>,
};
const VIEW_MODE_CHAT = 'view_chat';
const VIEW_MODE_SUPER_CHAT = 'view_superchat';
const COMMENT_SCROLL_OFFSET = 100;
const COMMENT_SCROLL_TIMEOUT = 25;
export default function LivestreamComments(props: Props) {
@ -38,36 +37,32 @@ export default function LivestreamComments(props: Props) {
embed,
doCommentSocketConnect,
doCommentSocketDisconnect,
comments,
comments: commentsByChronologicalOrder,
pinnedComments,
doCommentList,
fetchingComments,
doSuperChatList,
superChats,
superChatsTotalAmount,
myChannels,
superChats: superChatsByTipAmount,
} = props;
let superChatsFiatAmount, superChatsTotalAmount;
const commentsRef = React.createRef();
const [scrollBottom, setScrollBottom] = React.useState(true);
const [viewMode, setViewMode] = React.useState(VIEW_MODE_CHAT);
const [performedInitialScroll, setPerformedInitialScroll] = React.useState(false);
const [scrollPos, setScrollPos] = React.useState(0);
const claimId = claim && claim.claim_id;
const commentsLength = comments && comments.length;
const commentsToDisplay = viewMode === VIEW_MODE_CHAT ? comments : superChats;
const commentsLength = commentsByChronologicalOrder && commentsByChronologicalOrder.length;
const commentsToDisplay = viewMode === VIEW_MODE_CHAT ? commentsByChronologicalOrder : superChatsByTipAmount;
const discussionElement = document.querySelector('.livestream__comments');
const commentElement = document.querySelector('.livestream-comment');
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
function isMyComment(channelId: string) {
if (myChannels != null && channelId != null) {
for (let i = 0; i < myChannels.length; i++) {
if (myChannels[i].claim_id === channelId) {
return true;
}
}
const pinnedComment = pinnedComments.length > 0 ? pinnedComments[0] : null;
function restoreScrollPos() {
if (discussionElement) {
discussionElement.scrollTop = 0;
}
return false;
}
React.useEffect(() => {
@ -84,87 +79,142 @@ export default function LivestreamComments(props: Props) {
};
}, [claimId, uri, doCommentList, doSuperChatList, doCommentSocketConnect, doCommentSocketDisconnect]);
const handleScroll = React.useCallback(() => {
if (discussionElement) {
const negativeCommentHeight = commentElement && -1 * commentElement.offsetHeight;
const isAtRecent = negativeCommentHeight && discussionElement.scrollTop >= negativeCommentHeight;
setScrollBottom(isAtRecent);
}
}, [commentElement, discussionElement]);
// Register scroll handler (TODO: Should throttle/debounce)
React.useEffect(() => {
if (discussionElement) {
discussionElement.addEventListener('scroll', handleScroll);
if (commentsLength > 0) {
// Only update comment scroll if the user hasn't scrolled up to view old comments
// If they have, do nothing
if (!performedInitialScroll) {
setTimeout(
() =>
(discussionElement.scrollTop =
discussionElement.scrollHeight - discussionElement.offsetHeight + COMMENT_SCROLL_OFFSET),
COMMENT_SCROLL_TIMEOUT
);
setPerformedInitialScroll(true);
function handleScroll() {
if (discussionElement) {
const scrollTop = discussionElement.scrollTop;
if (scrollTop !== scrollPos) {
setScrollPos(scrollTop);
}
}
}
if (discussionElement) {
discussionElement.addEventListener('scroll', handleScroll);
return () => discussionElement.removeEventListener('scroll', handleScroll);
}
}, [commentsLength, discussionElement, handleScroll, performedInitialScroll, setPerformedInitialScroll]);
}, [discussionElement, scrollPos]);
// Retain scrollPos=0 when receiving new messages.
React.useEffect(() => {
if (discussionElement && commentsLength > 0) {
// Only update comment scroll if the user hasn't scrolled up to view old comments
if (scrollPos >= 0) {
// +ve scrollPos: not scrolled (Usually, there'll be a few pixels beyond 0).
// -ve scrollPos: user scrolled.
const timer = setTimeout(() => {
// Use a timer here to ensure we reset after the new comment has been rendered.
discussionElement.scrollTop = 0;
}, COMMENT_SCROLL_TIMEOUT);
return () => clearTimeout(timer);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commentsLength]); // (Just respond to 'commentsLength' updates and nothing else)
// sum total amounts for fiat tips and lbc tips
if (superChatsByTipAmount) {
let fiatAmount = 0;
let LBCAmount = 0;
for (const superChat of superChatsByTipAmount) {
if (superChat.is_fiat) {
fiatAmount = fiatAmount + superChat.support_amount;
} else {
LBCAmount = LBCAmount + superChat.support_amount;
}
}
superChatsFiatAmount = fiatAmount;
superChatsTotalAmount = LBCAmount;
}
let superChatsReversed;
// array of superchats organized by fiat or not first, then support amount
if (superChatsByTipAmount) {
const clonedSuperchats = JSON.parse(JSON.stringify(superChatsByTipAmount));
// sort by fiat first then by support amount
superChatsReversed = clonedSuperchats
.sort((a, b) => {
// if both are fiat, organize by support
if (a.is_fiat === b.is_fiat) {
return b.support_amount - a.support_amount;
// otherwise, if they are not both fiat, put the fiat transaction first
} else {
return a.is_fiat === b.is_fiat ? 0 : a.is_fiat ? -1 : 1;
}
})
.reverse();
}
// todo: implement comment_list --mine in SDK so redux can grab with selectCommentIsMine
function isMyComment(channelId: string) {
if (myChannels != null && channelId != null) {
for (let i = 0; i < myChannels.length; i++) {
if (myChannels[i].claim_id === channelId) {
return true;
}
}
}
return false;
}
if (!claim) {
return null;
}
function scrollBack() {
if (discussionElement) {
discussionElement.scrollTop = 0;
setScrollBottom(true);
}
}
return (
<div className="card livestream__discussion">
<div className="card__header--between livestream-discussion__header">
<div className="livestream-discussion__title">{__('Live discussion')}</div>
{superChatsTotalAmount > 0 && (
{(superChatsTotalAmount || 0) > 0 && (
<div className="recommended-content__toggles">
{/* the superchats in chronological order button */}
<Button
className={classnames('button-toggle', {
'button-toggle--active': viewMode === VIEW_MODE_CHAT,
})}
label={__('Chat')}
onClick={() => setViewMode(VIEW_MODE_CHAT)}
onClick={() => {
setViewMode(VIEW_MODE_CHAT);
const livestreamCommentsDiv = document.getElementsByClassName('livestream__comments')[0];
livestreamCommentsDiv.scrollTop = livestreamCommentsDiv.scrollHeight;
}}
/>
{/* the button to show superchats listed by most to least support amount */}
<Button
className={classnames('button-toggle', {
'button-toggle--active': viewMode === VIEW_MODE_SUPER_CHAT,
})}
label={
<>
<CreditAmount amount={superChatsTotalAmount} size={8} /> {__('Tipped')}
<CreditAmount amount={superChatsTotalAmount || 0} size={8} /> /
<CreditAmount amount={superChatsFiatAmount || 0} size={8} isFiat /> {__('Tipped')}
</>
}
onClick={() => setViewMode(VIEW_MODE_SUPER_CHAT)}
onClick={() => {
setViewMode(VIEW_MODE_SUPER_CHAT);
const livestreamCommentsDiv = document.getElementsByClassName('livestream__comments')[0];
const divHeight = livestreamCommentsDiv.scrollHeight;
livestreamCommentsDiv.scrollTop = divHeight * -1;
}}
/>
</div>
)}
</div>
<>
{fetchingComments && !comments && (
{fetchingComments && !commentsByChronologicalOrder && (
<div className="main--empty">
<Spinner />
</div>
)}
<div ref={commentsRef} className="livestream__comments-wrapper">
{viewMode === VIEW_MODE_CHAT && superChatsTotalAmount > 0 && superChats && (
{viewMode === VIEW_MODE_CHAT && superChatsByTipAmount && (superChatsTotalAmount || 0) > 0 && (
<div className="livestream-superchats__wrapper">
<div className="livestream-superchats__inner">
{superChats.map((superChat: Comment) => (
{superChatsByTipAmount.map((superChat: Comment) => (
<Tooltip key={superChat.comment_id} label={superChat.comment}>
<div className="livestream-superchat">
<div className="livestream-superchat__thumbnail">
@ -187,36 +237,75 @@ export default function LivestreamComments(props: Props) {
</div>
)}
{!fetchingComments && comments.length > 0 ? (
{pinnedComment && (
<div className="livestream-pinned__wrapper">
<LivestreamComment
key={pinnedComment.comment_id}
uri={uri}
authorUri={pinnedComment.channel_url}
commentId={pinnedComment.comment_id}
message={pinnedComment.comment}
supportAmount={pinnedComment.support_amount}
isModerator={pinnedComment.is_moderator}
isGlobalMod={pinnedComment.is_global_mod}
isFiat={pinnedComment.is_fiat}
isPinned={pinnedComment.is_pinned}
commentIsMine={pinnedComment.channel_id && isMyComment(pinnedComment.channel_id)}
/>
</div>
)}
{/* top to bottom comment display */}
{!fetchingComments && commentsByChronologicalOrder.length > 0 ? (
<div className="livestream__comments">
{commentsToDisplay.map((comment) => (
<LivestreamComment
key={comment.comment_id}
uri={uri}
authorUri={comment.channel_url}
commentId={comment.comment_id}
message={comment.comment}
supportAmount={comment.support_amount}
isFiat={comment.is_fiat}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
/>
))}
{viewMode === VIEW_MODE_CHAT &&
commentsToDisplay.map((comment) => (
<LivestreamComment
key={comment.comment_id}
uri={uri}
authorUri={comment.channel_url}
commentId={comment.comment_id}
message={comment.comment}
supportAmount={comment.support_amount}
isModerator={comment.is_moderator}
isGlobalMod={comment.is_global_mod}
isFiat={comment.is_fiat}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
/>
))}
{viewMode === VIEW_MODE_SUPER_CHAT &&
superChatsReversed &&
superChatsReversed.map((comment) => (
<LivestreamComment
key={comment.comment_id}
uri={uri}
authorUri={comment.channel_url}
commentId={comment.comment_id}
message={comment.comment}
supportAmount={comment.support_amount}
isModerator={comment.is_moderator}
isGlobalMod={comment.is_global_mod}
isFiat={comment.is_fiat}
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
/>
))}
</div>
) : (
<div className="main--empty" style={{ flex: 1 }} />
)}
{!scrollBottom && (
{scrollPos < 0 && (
<Button
button="alt"
className="livestream__comments-scroll__down"
label={__('Recent Comments')}
onClick={scrollBack}
onClick={restoreScrollPos}
/>
)}
<div className="livestream__comment-create">
<CommentCreate livestream bottom embed={embed} uri={uri} />
<CommentCreate livestream bottom embed={embed} uri={uri} onDoneReplying={restoreScrollPos} />
</div>
</div>
</>

View file

@ -16,6 +16,8 @@ import FileThumbnail from 'component/fileThumbnail';
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import NotificationContentChannelMenu from 'component/notificationContentChannelMenu';
import LbcMessage from 'component/common/lbc-message';
import UriIndicator from 'component/uriIndicator';
import { NavLink } from 'react-router-dom';
type Props = {
notification: WebNotification,
@ -29,13 +31,12 @@ export default function Notification(props: Props) {
const { notification, menuButton = false, doReadNotifications, doDeleteNotification } = props;
const { push } = useHistory();
const { notification_rule, notification_parameters, is_read, id } = notification;
const isCommentNotification =
notification_rule === RULE.COMMENT ||
notification_rule === RULE.COMMENT_REPLY ||
notification_rule === RULE.CREATOR_COMMENT;
const commentText = isCommentNotification && notification_parameters.dynamic.comment;
const channelUrl =
(notification_rule === RULE.NEW_CONTENT && notification.notification_parameters.dynamic.channel_url) || '';
let notificationTarget;
switch (notification_rule) {
@ -51,21 +52,14 @@ export default function Notification(props: Props) {
notificationTarget = notification_parameters.device.target;
}
let notificationLink = formatLbryUrlForWeb(notificationTarget);
let urlParams = new URLSearchParams();
if (isCommentNotification && notification_parameters.dynamic.hash) {
urlParams.append('lc', notification_parameters.dynamic.hash);
}
try {
const { isChannel } = parseURI(notificationTarget);
if (isChannel) {
urlParams.append(PAGE_VIEW_QUERY, DISCUSSION_PAGE);
}
} catch (e) {}
notificationLink += `?${urlParams.toString()}`;
const creatorIcon = (channelUrl) => {
return (
<UriIndicator uri={channelUrl} link>
<ChannelThumbnail small uri={channelUrl} />
</UriIndicator>
);
};
let channelUrl;
let icon;
switch (notification_rule) {
case RULE.CREATOR_SUBSCRIBER:
@ -73,16 +67,20 @@ export default function Notification(props: Props) {
break;
case RULE.COMMENT:
case RULE.CREATOR_COMMENT:
icon = <ChannelThumbnail small uri={notification_parameters.dynamic.comment_author} />;
channelUrl = notification_parameters.dynamic.comment_author;
icon = creatorIcon(channelUrl);
break;
case RULE.COMMENT_REPLY:
icon = <ChannelThumbnail small uri={notification_parameters.dynamic.reply_author} />;
channelUrl = notification_parameters.dynamic.reply_author;
icon = creatorIcon(channelUrl);
break;
case RULE.NEW_CONTENT:
icon = <ChannelThumbnail small uri={notification_parameters.dynamic.channel_url} />;
channelUrl = notification_parameters.dynamic.channel_url;
icon = creatorIcon(channelUrl);
break;
case RULE.NEW_LIVESTREAM:
icon = <ChannelThumbnail small uri={notification_parameters.dynamic.channel_url} />;
channelUrl = notification_parameters.dynamic.channel_url;
icon = creatorIcon(channelUrl);
break;
case RULE.DAILY_WATCH_AVAILABLE:
case RULE.DAILY_WATCH_REMIND:
@ -97,12 +95,54 @@ export default function Notification(props: Props) {
icon = <Icon icon={ICONS.NOTIFICATION} sectionIcon />;
}
let notificationLink = formatLbryUrlForWeb(notificationTarget);
let urlParams = new URLSearchParams();
if (isCommentNotification && notification_parameters.dynamic.hash) {
urlParams.append('lc', notification_parameters.dynamic.hash);
}
let channelName = channelUrl && '@' + channelUrl.split('@')[1].split('#')[0];
const notificationTitle = notification_parameters.device.title;
const titleSplit = notificationTitle.split(' ');
let fullTitle = [' '];
let uriIndicator;
const title = titleSplit.map((message, index) => {
if (channelName === message) {
uriIndicator = <UriIndicator uri={channelUrl} link />;
fullTitle.push(' ');
const resultTitle = fullTitle;
fullTitle = [' '];
return [resultTitle.join(' '), uriIndicator];
} else {
fullTitle.push(message);
if (index === titleSplit.length - 1) {
return <LbcMessage>{fullTitle.join(' ')}</LbcMessage>;
}
}
});
try {
const { isChannel } = parseURI(notificationTarget);
if (isChannel) {
urlParams.append(PAGE_VIEW_QUERY, DISCUSSION_PAGE);
}
} catch (e) {}
notificationLink += `?${urlParams.toString()}`;
const navLinkProps = {
to: notificationLink,
onClick: (e) => e.stopPropagation(),
};
function handleNotificationClick() {
if (!is_read) {
doReadNotifications([id]);
}
if (notificationLink) {
if (menuButton && notificationLink) {
push(notificationLink);
}
}
@ -120,9 +160,11 @@ export default function Notification(props: Props) {
)
: notificationLink
? (props: { children: any }) => (
<a className="menu__link--notification" onClick={handleNotificationClick}>
{props.children}
</a>
<NavLink {...navLinkProps}>
<a className="menu__link--notification" onClick={handleNotificationClick}>
{props.children}
</a>
</NavLink>
)
: (props: { children: any }) => (
<span
@ -145,17 +187,11 @@ export default function Notification(props: Props) {
<div className="notification__content-wrapper">
<div className="notification__content">
<div className="notification__text-wrapper">
{!isCommentNotification && (
<div className="notification__title">
<LbcMessage>{notification_parameters.device.title}</LbcMessage>
</div>
)}
{!isCommentNotification && <div className="notification__title">{title}</div>}
{isCommentNotification && commentText ? (
<>
<div className="notification__title">
<LbcMessage>{notification_parameters.device.title}</LbcMessage>
</div>
<div className="notification__title">{title}</div>
<div title={commentText} className="notification__text mobile-hidden">
{commentText}
</div>
@ -193,7 +229,13 @@ export default function Notification(props: Props) {
<div className="notification__menu">
<Menu>
<MenuButton className={'menu__button notification__menu-button'} onClick={(e) => e.stopPropagation()}>
<MenuButton
className={'menu__button notification__menu-button'}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icon size={18} icon={ICONS.MORE_VERTICAL} />
</MenuButton>
<MenuList className="menu__list">

View file

@ -18,7 +18,12 @@ import {
} from 'lbry-redux';
import * as RENDER_MODES from 'constants/file_render_modes';
import { doPublishDesktop } from 'redux/actions/publish';
import { selectUnclaimedRewardValue } from 'redux/selectors/rewards';
import { doClaimInitialRewards } from 'redux/actions/rewards';
import {
selectUnclaimedRewardValue,
selectIsClaimingInitialRewards,
selectHasClaimedInitialRewards,
} from 'redux/selectors/rewards';
import {
selectModal,
selectActiveChannelClaim,
@ -27,8 +32,8 @@ import {
} from 'redux/selectors/app';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { makeSelectFileRenderModeForUri } from 'redux/selectors/content';
import PublishPage from './view';
import { selectUser } from 'redux/selectors/user';
import PublishPage from './view';
const select = (state) => {
const myClaimForUri = selectMyClaimForUri(state);
@ -59,6 +64,8 @@ const select = (state) => {
myChannels: selectMyChannelClaims(state),
incognito: selectIncognito(state),
activeChannelStakedLevel: selectActiveChannelStakedLevel(state),
isClaimingInitialRewards: selectIsClaimingInitialRewards(state),
hasClaimedInitialRewards: selectHasClaimedInitialRewards(state),
};
};
@ -70,6 +77,7 @@ const perform = (dispatch) => ({
prepareEdit: (claim, uri) => dispatch(doPrepareEdit(claim, uri)),
resetThumbnailStatus: () => dispatch(doResetThumbnailStatus()),
checkAvailability: (name) => dispatch(doCheckPublishNameAvailability(name)),
claimInitialRewards: () => dispatch(doClaimInitialRewards()),
});
export default connect(select, perform)(PublishPage);

View file

@ -90,6 +90,9 @@ type Props = {
isPostClaim: boolean,
permanentUrl: ?string,
remoteUrl: ?string,
isClaimingInitialRewards: boolean,
claimInitialRewards: () => void,
hasClaimedInitialRewards: boolean,
};
function PublishForm(props: Props) {
@ -128,6 +131,9 @@ function PublishForm(props: Props) {
isPostClaim,
permanentUrl,
remoteUrl,
isClaimingInitialRewards,
claimInitialRewards,
hasClaimedInitialRewards,
} = props;
const { replace, location } = useHistory();
@ -263,6 +269,12 @@ function PublishForm(props: Props) {
}
}, [activeChannelClaimStr, setSignedMessage]);
useEffect(() => {
if (!hasClaimedInitialRewards) {
claimInitialRewards();
}
}, [hasClaimedInitialRewards, claimInitialRewards]);
useEffect(() => {
if (!modal) {
setTimeout(() => {
@ -298,7 +310,10 @@ function PublishForm(props: Props) {
const isLivestreamMode = mode === PUBLISH_MODES.LIVESTREAM;
let submitLabel;
if (publishing) {
if (isClaimingInitialRewards) {
submitLabel = __('Claiming credits...');
} else if (publishing) {
if (isStillEditing) {
submitLabel = __('Saving...');
} else if (isLivestreamMode) {
@ -623,6 +638,7 @@ function PublishForm(props: Props) {
onClick={handlePublish}
label={submitLabel}
disabled={
isClaimingInitialRewards ||
formDisabled ||
!formValid ||
uploadThumbnailStatus === THUMBNAIL_STATUSES.IN_PROGRESS ||

View file

@ -15,6 +15,7 @@ import ClaimPreview from 'component/claimPreview';
import { URL as SITE_URL, URL_LOCAL, URL_DEV } from 'config';
import HelpLink from 'component/common/help-link';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import BidHelpText from 'component/publishBid/bid-help-text';
import Spinner from 'component/spinner';
type Props = {
@ -77,7 +78,6 @@ function RepostCreate(props: Props) {
const [repostBid, setRepostBid] = React.useState(0.01);
const [repostBidError, setRepostBidError] = React.useState(undefined);
const [takeoverAmount, setTakeoverAmount] = React.useState(0);
const [enteredRepostName, setEnteredRepostName] = React.useState(defaultName);
const [available, setAvailable] = React.useState(true);
const [enteredContent, setEnteredContentUri] = React.useState(undefined);
@ -189,10 +189,9 @@ function RepostCreate(props: Props) {
: Number(passedRepostAmount) + 0.01;
if (repostTakeoverAmount) {
setTakeoverAmount(Number(repostTakeoverAmount.toFixed(2)));
setAutoRepostBid(repostTakeoverAmount);
}
}, [setTakeoverAmount, enteredRepostAmount, passedRepostAmount]);
}, [enteredRepostAmount, passedRepostAmount]);
// repost bid error
React.useEffect(() => {
@ -381,9 +380,11 @@ function RepostCreate(props: Props) {
error={repostBidError}
helper={
<>
{__('Winning amount: %amount%', {
amount: Number(takeoverAmount).toFixed(2),
})}
<BidHelpText
uri={'lbry://' + enteredRepostName}
amountNeededForTakeover={enteredRepostAmount}
isResolvingUri={isResolvingEnteredRepost}
/>
<WalletSpendableBalanceHelp inline />
</>
}

View file

@ -0,0 +1,17 @@
import { connect } from 'react-redux';
import { SETTINGS } from 'lbry-redux';
import { doSetClientSetting } from 'redux/actions/settings';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import SettingCommentsServer from './view';
const select = (state) => ({
customServerEnabled: makeSelectClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_ENABLED)(state),
customServerUrl: makeSelectClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_URL)(state),
});
const perform = (dispatch) => ({
setCustomServerEnabled: (val) => dispatch(doSetClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_ENABLED, val, true)),
setCustomServerUrl: (url) => dispatch(doSetClientSetting(SETTINGS.CUSTOM_COMMENTS_SERVER_URL, url, true)),
});
export default connect(select, perform)(SettingCommentsServer);

View file

@ -0,0 +1,70 @@
// @flow
import { COMMENT_SERVER_NAME } from 'config';
import React from 'react';
import Comments from 'comments';
import { FormField } from 'component/common/form';
const DEBOUNCE_TEXT_INPUT_MS = 500;
type Props = {
customServerEnabled: boolean,
customServerUrl: string,
setCustomServerEnabled: (boolean) => void,
setCustomServerUrl: (string) => void,
};
function SettingCommentsServer(props: Props) {
const { customServerEnabled, customServerUrl, setCustomServerEnabled, setCustomServerUrl } = props;
const [customUrl, setCustomUrl] = React.useState(customServerUrl);
React.useEffect(() => {
const timer = setTimeout(() => {
setCustomServerUrl(customUrl);
Comments.url = customUrl;
}, DEBOUNCE_TEXT_INPUT_MS);
return () => clearTimeout(timer);
}, [customUrl, setCustomServerUrl]);
return (
<React.Fragment>
<fieldset-section>
<FormField
type="radio"
name="use_default_comments_server"
label={__('Default comments server (%name%)', { name: COMMENT_SERVER_NAME })}
checked={!customServerEnabled}
onChange={(e) => {
if (e.target.checked) {
setCustomServerEnabled(false);
}
}}
/>
<FormField
type="radio"
name="use_custom_comments_server"
label={__('Custom comments server')}
checked={customServerEnabled}
onChange={(e) => {
if (e.target.checked) {
setCustomServerEnabled(true);
}
}}
/>
{customServerEnabled && (
<div className="section__body">
<FormField
type="text"
placeholder="https://comment.mysite.com"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
/>
</div>
)}
</fieldset-section>
</React.Fragment>
);
}
export default SettingCommentsServer;

View file

@ -28,6 +28,9 @@ function UserChannelFollowIntro(props: Props) {
channelIds = PRIMARY_CONTENT.channelIds;
}
const followingCount = (subscribedChannels && subscribedChannels.length) || 0;
const followingCountIgnoringAutoFollows = (subscribedChannels || []).filter(
(channel) => !channelsToSubscribe.includes(channel.uri)
).length;
// subscribe to lbry
useEffect(() => {
@ -74,9 +77,13 @@ function UserChannelFollowIntro(props: Props) {
<Nag
type="helpful"
message={
followingCount === 1
? __('Nice! You are currently following %followingCount% creator', { followingCount })
: __('Nice! You are currently following %followingCount% creators', { followingCount })
followingCountIgnoringAutoFollows === 1
? __('Nice! You are currently following %followingCount% creator', {
followingCount: followingCountIgnoringAutoFollows,
})
: __('Nice! You are currently following %followingCount% creators', {
followingCount: followingCountIgnoringAutoFollows,
})
}
actionText={__('Continue')}
onClick={onContinue}

View file

@ -97,7 +97,7 @@ class AniviewPlugin extends Component {
}
}
videojs.registerComponent('recsys', AniviewPlugin);
videojs.registerComponent('aniview', AniviewPlugin);
const onPlayerReady = (player, options) => {
player.aniview = new AniviewPlugin(player, options);

View file

@ -6,6 +6,7 @@ import {
makeSelectRecommendedClaimIds,
makeSelectRecommendationClicks,
} from 'redux/selectors/content';
import { makeSelectRecommendedRecsysIdForClaimId } from 'redux/selectors/search';
const VERSION = '0.0.1';
@ -36,7 +37,7 @@ function createRecsys(claimId, userId, events, loadedAt, isEmbed) {
claimId: claimId,
pageLoadedAt: pageLoadedAt,
pageExitedAt: pageExitedAt,
recsysId: recsysId,
recsysId: makeSelectRecommendedRecsysIdForClaimId(claimId)(state) || recsysId,
recClaimIds: makeSelectRecommendedClaimIds(claimId)(state),
recClickedVideoIdx: makeSelectRecommendationClicks(claimId)(state),
events: events,

View file

@ -13,8 +13,9 @@ import hlsQualitySelector from './plugins/videojs-hls-quality-selector/plugin';
import recsys from './plugins/videojs-recsys/plugin';
import qualityLevels from 'videojs-contrib-quality-levels';
import isUserTyping from 'util/detect-typing';
// @if TARGET='web'
import './plugins/videojs-aniview/plugin';
// @endif
const isDev = process.env.NODE_ENV !== 'production';
export type Player = {
@ -585,9 +586,11 @@ export default React.memo<Props>(function VideoJs(props: Props) {
// This must be initialized earlier than everything else
// otherwise a race condition occurs if we place this in the onReady call back
// allow if isDev because otherwise you'll never see ads when basing to master
// @if TARGET='web'
if ((allowPreRoll && SIMPLE_SITE) || isDev) {
vjs.aniview();
}
// @endif
// fixes #3498 (https://github.com/lbryio/lbry-desktop/issues/3498)
// summary: on firefox the focus would stick to the fullscreen button which caused buggy behavior with spacebar
@ -662,6 +665,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
}, [source, reload]);
// Load IMA3 SDK for aniview
// @if TARGET='web'
useEffect(() => {
const script = document.createElement('script');
script.src = `https://imasdk.googleapis.com/js/sdkloader/ima3.js`;
@ -674,6 +678,7 @@ export default React.memo<Props>(function VideoJs(props: Props) {
document.body.removeChild(script);
};
});
// @endif
return (
// $FlowFixMe

View file

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

View file

@ -0,0 +1,88 @@
// @flow
import React from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import moment from 'moment';
type Props = {
accountDetails: any,
transactions: any,
};
const WalletBalance = (props: Props) => {
// receive transactions from parent component
const { transactions } = props;
let accountTransactions;
// reverse so most recent payments come first
if (transactions && transactions.length) {
accountTransactions = transactions.reverse();
}
// if there are more than 10 transactions, limit it to 10 for the frontend
if (accountTransactions && accountTransactions.length > 10) {
accountTransactions.length = 10;
}
return (
<><Card
title={'Tip History'}
body={(
<>
<div className="table__wrapper">
<table className="table table--transactions">
<thead>
<tr>
<th className="date-header">{__('Date')}</th>
<th>{<>{__('Receiving Channel Name')}</>}</th>
<th>{__('Tip Location')}</th>
<th>{__('Amount (USD)')} </th>
<th>{__('Processing Fee')}</th>
<th>{__('Odysee Fee')}</th>
<th>{__('Received Amount')}</th>
</tr>
</thead>
<tbody>
{accountTransactions &&
accountTransactions.map((transaction) => (
<tr key={transaction.name + transaction.created_at}>
<td>{moment(transaction.created_at).format('LLL')}</td>
<td>
<Button
className=""
navigate={'/' + transaction.channel_name + ':' + transaction.channel_claim_id}
label={transaction.channel_name}
button="link"
/>
</td>
<td>
<Button
className=""
navigate={'/' + transaction.channel_name + ':' + transaction.source_claim_id}
label={
transaction.channel_claim_id === transaction.source_claim_id
? 'Channel Page'
: 'Content Page'
}
button="link"
/>
</td>
<td>${transaction.tipped_amount / 100}</td>
<td>${transaction.transaction_fee / 100}</td>
<td>${transaction.application_fee / 100}</td>
<td>${transaction.received_amount / 100}</td>
</tr>
))}
</tbody>
</table>
{!accountTransactions && <p style={{textAlign: 'center', marginTop: '20px', fontSize: '13px', color: 'rgb(171, 171, 171)'}}>No Transactions</p>}
</div>
</>
)}
/>
</>
);
};
export default WalletBalance;

View file

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

View file

@ -0,0 +1,94 @@
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import React from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import Icon from 'component/common/icon';
import I18nMessage from 'component/i18nMessage';
type Props = {
accountDetails: any,
};
const WalletBalance = (props: Props) => {
const {
accountDetails,
} = props;
return (
<>{<Card
title={<><Icon size={18} icon={ICONS.FINANCE} />{(accountDetails && ((accountDetails.total_received_unpaid - accountDetails.total_paid_out) / 100)) || 0} USD</>}
subtitle={accountDetails && accountDetails.total_received_unpaid > 0 &&
<I18nMessage>
This is your pending balance that will be automatically sent to your bank account
</I18nMessage>
}
actions={
<>
<h2 className="section__title--small">
${(accountDetails && (accountDetails.total_received_unpaid / 100)) || 0} Total Received Tips
</h2>
<h2 className="section__title--small">
${(accountDetails && (accountDetails.total_paid_out / 100)) || 0} Withdrawn
{/* <Button */}
{/* button="link" */}
{/* label={detailsExpanded ? __('View less') : __('View more')} */}
{/* iconRight={detailsExpanded ? ICONS.UP : ICONS.DOWN} */}
{/* onClick={() => setDetailsExpanded(!detailsExpanded)} */}
{/* /> */}
</h2>
{/* view more section */}
{/* commenting out because not implemented, but could be used in the future */}
{/* {detailsExpanded && ( */}
{/* <div className="section__subtitle"> */}
{/* <dl> */}
{/* <dt> */}
{/* <span className="dt__text">{__('Earned from uploads')}</span> */}
{/* /!* <span className="help--dt">({__('Earned from channel page')})</span> *!/ */}
{/* </dt> */}
{/* <dd> */}
{/* <span className="dd__text"> */}
{/* {Boolean(1) && ( */}
{/* <Button */}
{/* button="link" */}
{/* className="dd__button" */}
{/* icon={ICONS.UNLOCK} */}
{/* /> */}
{/* )} */}
{/* <CreditAmount amount={1} precision={4} /> */}
{/* </span> */}
{/* </dd> */}
{/* <dt> */}
{/* <span className="dt__text">{__('Earned from channel page')}</span> */}
{/* /!* <span className="help--dt">({__('Delete or edit past content to spend')})</span> *!/ */}
{/* </dt> */}
{/* <dd> */}
{/* <CreditAmount amount={1} precision={4} /> */}
{/* </dd> */}
{/* /!* <dt> *!/ */}
{/* /!* <span className="dt__text">{__('...supporting content')}</span> *!/ */}
{/* /!* <span className="help--dt">({__('Delete supports to spend')})</span> *!/ */}
{/* /!* </dt> *!/ */}
{/* /!* <dd> *!/ */}
{/* /!* <CreditAmount amount={1} precision={4} /> *!/ */}
{/* /!* </dd> *!/ */}
{/* </dl> */}
{/* </div> */}
{/* )} */}
<div className="section__actions">
{/* <Button button="primary" label={__('Receive Payout')} icon={ICONS.SEND} /> */}
<Button button="secondary" label={__('Account Configuration')} icon={ICONS.SETTINGS} navigate={`/$/${PAGES.SETTINGS_STRIPE_ACCOUNT}`} />
</div>
</>
}
/>}</>
);
};
export default WalletBalance;

View file

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

View file

@ -0,0 +1,76 @@
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import React from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
type Props = {
totalTippedAmount: number,
accountDetails: any,
transactions: any,
};
const WalletBalance = (props: Props) => {
const {
// accountDetails,
transactions,
} = props;
// let cardDetails = {
// brand: card.brand,
// expiryYear: card.exp_year,
// expiryMonth: card.exp_month,
// lastFour: card.last4,
// topOfDisplay: topOfDisplay,
// bottomOfDisplay: bottomOfDisplay,
// };
// const [detailsExpanded, setDetailsExpanded] = React.useState(false);
const [totalCreatorsSupported, setTotalCreatorsSupported] = React.useState(false);
// calculate how many unique users tipped
React.useEffect(() => {
if (transactions) {
let channelNames = [];
for (const transaction of transactions) {
channelNames.push(transaction.channel_name);
}
let unique = [...new Set(channelNames)];
setTotalCreatorsSupported(unique.length);
}
}, [transactions]);
return (
<>{<Card
// TODO: implement hasActiveCard and show the current card the user would charge to
// subtitle={hasActiveCard && <h2>Hello</h2>
// // <Plastic
// // type={userCardDetails.brand}
// // name={userCardDetails.topOfDisplay + ' ' + userCardDetails.bottomOfDisplay}
// // expiry={userCardDetails.expiryMonth + '/' + userCardDetails.expiryYear}
// // number={'____________' + userCardDetails.lastFour}
// // />
// }
actions={
<>
<h2 className="section__title--small">
{(transactions && transactions.length) || 0} Total Tips
</h2>
<h2 className="section__title--small">
{totalCreatorsSupported || 0} Creators Supported
</h2>
<div className="section__actions">
<Button button="secondary" label={__('Manage Cards')} icon={ICONS.SETTINGS} navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} />
</div>
</>
}
/>}</>
);
};
export default WalletBalance;

View file

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

View file

@ -0,0 +1,113 @@
// @flow
import React from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import { Lbryio } from 'lbryinc';
import moment from 'moment';
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
type Props = {
accountDetails: any,
transactions: any,
};
const WalletBalance = (props: Props) => {
// receive transactions from parent component
const { transactions: accountTransactions } = props;
// const [accountStatusResponse, setAccountStatusResponse] = React.useState();
// const [subscriptions, setSubscriptions] = React.useState();
const [lastFour, setLastFour] = React.useState();
function getCustomerStatus() {
return Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
);
}
// TODO: this is actually incorrect, last4 should be populated based on the transaction not the current customer details
React.useEffect(() => {
(async function () {
const customerStatusResponse = await getCustomerStatus();
const lastFour =
customerStatusResponse.PaymentMethods &&
customerStatusResponse.PaymentMethods.length &&
customerStatusResponse.PaymentMethods[0].card.last4;
setLastFour(lastFour);
})();
}, []);
return (
<>
<Card
title={__('Payment History')}
body={
<>
<div className="table__wrapper">
<table className="table table--transactions">
<thead>
<tr>
<th className="date-header">{__('Date')}</th>
<th>{<>{__('Receiving Channel Name')}</>}</th>
<th>{__('Tip Location')}</th>
<th>{__('Amount (USD)')} </th>
<th>{__('Card Last 4')}</th>
<th>{__('Anonymous')}</th>
</tr>
</thead>
<tbody>
{accountTransactions &&
accountTransactions.map((transaction) => (
<tr key={transaction.name + transaction.created_at}>
<td>{moment(transaction.created_at).format('LLL')}</td>
<td>
<Button
className=""
navigate={'/' + transaction.channel_name + ':' + transaction.channel_claim_id}
label={transaction.channel_name}
button="link"
/>
</td>
<td>
<Button
className=""
navigate={'/' + transaction.channel_name + ':' + transaction.source_claim_id}
label={
transaction.channel_claim_id === transaction.source_claim_id
? 'Channel Page'
: 'Content Page'
}
button="link"
/>
</td>
<td>${transaction.tipped_amount / 100}</td>
<td>{lastFour}</td>
<td>{transaction.private_tip ? 'Yes' : 'No'}</td>
</tr>
))}
</tbody>
</table>
{(!accountTransactions || accountTransactions.length === 0) && (
<p style={{ textAlign: 'center', marginTop: '20px', fontSize: '13px', color: 'rgb(171, 171, 171)' }}>
No Transactions
</p>
)}
</div>
</>
}
/>
</>
);
};
export default WalletBalance;

View file

@ -15,14 +15,8 @@ import LbcSymbol from 'component/common/lbc-symbol';
import { parseURI } from 'lbry-redux';
import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import { STRIPE_PUBLIC_KEY } from 'config';
let stripeEnvironment = 'test';
// if the key contains pk_live it's a live key
// update the environment for the calls to the backend to indicate which environment to hit
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
stripeEnvironment = 'live';
}
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100];
const MINIMUM_FIAT_TIP = 1;
@ -100,7 +94,7 @@ function WalletSendTip(props: Props) {
// check if creator has a payment method saved
React.useEffect(() => {
if (channelClaimId && isAuthenticated) {
if (channelClaimId && isAuthenticated && stripeEnvironment) {
Lbryio.call(
'customer',
'status',
@ -118,16 +112,18 @@ function WalletSendTip(props: Props) {
setHasSavedCard(Boolean(defaultPaymentMethodId));
});
}
}, [channelClaimId, isAuthenticated]);
}, [channelClaimId, isAuthenticated, stripeEnvironment]);
// check if creator has an account saved
React.useEffect(() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
}, []);
React.useEffect(() => {
if (channelClaimId) {
if (channelClaimId && stripeEnvironment) {
Lbryio.call(
'account',
'check',
@ -141,20 +137,22 @@ function WalletSendTip(props: Props) {
.then((accountCheckResponse) => {
if (accountCheckResponse === true && canReceiveFiatTip !== true) {
setCanReceiveFiatTip(true);
} else {
setCanReceiveFiatTip(false);
}
})
.catch(function (error) {
// console.log(error);
});
}
}, [channelClaimId]);
}, [channelClaimId, stripeEnvironment]);
const noBalance = balance === 0;
const tipAmount = useCustomTip ? customTipAmount : presetTipAmount;
const [activeTab, setActiveTab] = React.useState(claimIsMine ? TAB_BOOST : TAB_LBC);
function setClaimTypeText() {
function getClaimTypeText() {
if (claim.value_type === 'stream') {
return __('Content');
} else if (claim.value_type === 'channel') {
@ -167,21 +165,24 @@ function WalletSendTip(props: Props) {
return __('Claim');
}
}
const claimTypeText = setClaimTypeText();
const claimTypeText = getClaimTypeText();
let iconToUse, explainerText;
let iconToUse;
let explainerText = '';
if (activeTab === TAB_BOOST) {
iconToUse = ICONS.LBC;
explainerText = __('This refundable boost will improve the discoverability of this %claimTypeText% while active.', {claimTypeText});
explainerText = __('This refundable boost will improve the discoverability of this %claimTypeText% while active.', {
claimTypeText,
});
} else if (activeTab === TAB_FIAT) {
iconToUse = ICONS.FINANCE;
explainerText = __('Show this channel your appreciation by sending a donation in USD. ');
explainerText = __('Show this channel your appreciation by sending a donation in USD.');
// if (!hasCardSaved) {
// explainerText += __('You must add a card to use this functionality.');
// }
} else if (activeTab === TAB_LBC) {
iconToUse = ICONS.LBC;
explainerText = __('Show this channel your appreciation by sending a donation of Credits. ');
explainerText = __('Show this channel your appreciation by sending a donation of Credits.');
}
const isSupport = claimIsMine || activeTab === TAB_BOOST;
@ -213,7 +214,7 @@ function WalletSendTip(props: Props) {
} else if (tipAmount < MINIMUM_PUBLISH_BID) {
tipError = __('Amount must be higher');
}
// if tip fiat tab
// if tip fiat tab
} else {
regexp = RegExp(/^(\d*([.]\d{0,2})?)$/);
const validTipInput = regexp.test(String(tipAmount));
@ -274,7 +275,8 @@ function WalletSendTip(props: Props) {
'customer',
'tip',
{
amount: 100 * tipAmount, // convert from dollars to cents
// round to fix issues with floating point numbers
amount: Math.round(100 * tipAmount), // convert from dollars to cents
creator_channel_name: tipChannelName, // creator_channel_name
creator_channel_claim_id: channelClaimId,
tipper_channel_name: sendAnonymously ? '' : activeChannelName,
@ -294,8 +296,8 @@ function WalletSendTip(props: Props) {
}),
});
})
.catch(function(error) {
var displayError = 'Sorry, there was an error in processing your payment!';
.catch(function (error) {
let displayError = 'Sorry, there was an error in processing your payment!';
if (error.message !== 'payment intent failed to confirm') {
displayError = error.message;
@ -313,10 +315,53 @@ function WalletSendTip(props: Props) {
}
}
function handleCustomPriceChange(event: SyntheticInputEvent<*>) {
const tipAmount = parseFloat(event.target.value);
const countDecimals = function (value) {
const text = value.toString();
const index = text.indexOf('.');
return text.length - index - 1;
};
setCustomTipAmount(tipAmount);
function handleCustomPriceChange(event: SyntheticInputEvent<*>) {
let tipAmountAsString = event.target.value;
let tipAmount = parseFloat(tipAmountAsString);
const howManyDecimals = countDecimals(tipAmountAsString);
// fiat tip input
if (activeTab === TAB_FIAT) {
if (Number.isNaN(tipAmount)) {
setCustomTipAmount('');
}
// allow maximum of two decimal places
if (howManyDecimals > 2) {
tipAmount = Math.floor(tipAmount * 100) / 100;
}
// remove decimals, and then get number of digits
const howManyDigits = Math.trunc(tipAmount).toString().length;
if (howManyDigits > 4 && tipAmount !== 1000) {
setTipError('Amount cannot be over 1000 dollars');
setCustomTipAmount(tipAmount);
} else if (tipAmount > 1000) {
setTipError('Amount cannot be over 1000 dollars');
setCustomTipAmount(tipAmount);
} else {
setCustomTipAmount(tipAmount);
}
// LBC tip input
} else {
// TODO: this is a bit buggy, needs a touchup
// if (howManyDecimals > 9) {
// // only allows up to 8 decimal places
// tipAmount = Number(tipAmount.toString().match(/^-?\d+(?:\.\d{0,8})?/)[0]);
//
// setTipError('Please only use up to 8 decimals');
// }
setCustomTipAmount(tipAmount);
}
}
function buildButtonText() {
@ -331,11 +376,19 @@ function WalletSendTip(props: Props) {
return false;
}
function convertToTwoDecimals(number) {
return (Math.round(number * 100) / 100).toFixed(2);
}
const amountToShow = activeTab === TAB_FIAT ? convertToTwoDecimals(tipAmount) : tipAmount;
// if it's a valid number display it, otherwise do an empty string
const displayAmount = !isNan(tipAmount) ? tipAmount : '';
const displayAmount = !isNan(tipAmount) ? amountToShow : '';
if (activeTab === TAB_BOOST) {
return (claimIsMine ? __('Boost Your %claimTypeText%', {claimTypeText}) : __('Boost This %claimTypeText%', {claimTypeText}));
return claimIsMine
? __('Boost Your %claimTypeText%', { claimTypeText })
: __('Boost This %claimTypeText%', { claimTypeText });
} else if (activeTab === TAB_FIAT) {
return __('Send a $%displayAmount% Tip', { displayAmount });
} else if (activeTab === TAB_LBC) {
@ -362,240 +415,263 @@ function WalletSendTip(props: Props) {
return (
<Form onSubmit={handleSubmit}>
{/* if there is no LBC balance, show user frontend to get credits */}
{noBalance ? (
<Card
title={<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>Supporting content requires %lbc%</I18nMessage>}
subtitle={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>
With %lbc%, you can send tips to your favorite creators, or help boost their content for more people to
see.
</I18nMessage>
}
actions={
<div className="section__actions">
<Button
icon={ICONS.REWARDS}
button="primary"
label={__('Earn Rewards')}
navigate={`/$/${PAGES.REWARDS}`}
/>
<Button icon={ICONS.BUY} button="secondary" label={__('Buy/Swap Credits')} navigate={`/$/${PAGES.BUY}`} />
<Button button="link" label={__('Nevermind')} onClick={closeModal} />
</div>
}
/>
) : (
// if there is lbc, the main tip/boost gui with the 3 tabs at the top
<Card
title={<LbcSymbol postfix={claimIsMine ? __('Boost Your %claimTypeText%', {claimTypeText}) : __('Support This %claimTypeText%', {claimTypeText})} size={22} />}
subtitle={
<React.Fragment>
{!claimIsMine && (
<div className="section">
{/* tip LBC tab button */}
<Button
key="tip"
icon={ICONS.LBC}
label={__('Tip')}
button="alt"
onClick={() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
if (!isConfirming) {
setActiveTab(TAB_LBC);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_LBC })}
/>
{/* tip fiat tab button */}
{/* @if TARGET='web' */}
{/* if there is lbc, the main tip/boost gui with the 3 tabs at the top */}
<Card
title={
<LbcSymbol
postfix={
claimIsMine
? __('Boost Your %claimTypeText%', { claimTypeText })
: __('Support This %claimTypeText%', { claimTypeText })
}
size={22}
/>
}
subtitle={
<React.Fragment>
{!claimIsMine && (
<div className="section">
{/* tip LBC tab button */}
<Button
key="tip"
icon={ICONS.LBC}
label={__('Tip')}
button="alt"
onClick={() => {
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
if (!isConfirming) {
setActiveTab(TAB_LBC);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_LBC })}
/>
{/* tip fiat tab button */}
{/* @if TARGET='web' */}
{stripeEnvironment && (
<Button
key="tip-fiat"
icon={ICONS.FINANCE}
label={__('Tip')}
button="alt"
onClick={() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
if (!isConfirming) {
setActiveTab(TAB_FIAT);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_FIAT })}
/>
{/* @endif */}
{/* tip LBC tab button */}
)}
{/* @endif */}
{/* tip LBC tab button */}
<Button
key="boost"
icon={ICONS.TRENDING}
label={__('Boost')}
button="alt"
onClick={() => {
const tipInputElement = document.getElementById('tip-input');
if (tipInputElement) {
tipInputElement.focus();
}
if (!isConfirming) {
setActiveTab(TAB_BOOST);
}
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_BOOST })}
/>
</div>
)}
{/* short explainer under the button */}
<div className="section__subtitle">
{explainerText + ' '}
{/* {activeTab === TAB_FIAT && !hasCardSaved && <Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add A Card')} button="link" />} */}
{<Button label={__('Learn more')} button="link" href="https://lbry.com/faq/tipping" />}
</div>
</React.Fragment>
}
actions={
// confirmation modal, allow user to confirm or cancel transaction
isConfirming ? (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
<div className="confirm__label">{__('To --[the tip recipient]--')}</div>
<div className="confirm__value">{channelName || title}</div>
<div className="confirm__label">{__('From --[the tip sender]--')}</div>
<div className="confirm__value">
{activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')}
</div>
<div className="confirm__label">{setConfirmLabel()}</div>
<div className="confirm__value">
{activeTab === TAB_FIAT ? (
<p>$ {(Math.round(tipAmount * 100) / 100).toFixed(2)}</p>
) : (
<LbcSymbol postfix={tipAmount} size={22} />
)}
</div>
</div>
</div>
<div className="section__actions">
<Button autoFocus onClick={handleSubmit} button="primary" disabled={isPending} label={__('Confirm')} />
<Button button="link" label={__('Cancel')} onClick={() => setIsConfirming(false)} />
</div>
</>
) : !((activeTab === TAB_LBC || activeTab === TAB_BOOST) && noBalance) ? (
<>
<div className="section">
<ChannelSelector />
</div>
{activeTab === TAB_FIAT && !hasCardSaved && (
<h3 className="add-card-prompt">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card')} button="link" />{' '}
{__('To Tip Creators')}
</h3>
)}
{/* section to pick tip/boost amount */}
<div className="section">
{DEFAULT_TIP_AMOUNTS.map((amount) => (
<Button
key="boost"
icon={ICONS.TRENDING}
label={__('Boost')}
key={amount}
disabled={shouldDisableAmountSelector(amount)}
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': tipAmount === amount && !useCustomTip,
'button-toggle--disabled': amount > balance,
})}
label={amount}
icon={iconToUse}
onClick={() => {
var tipInputElement = document.getElementById('tip-input');
if (tipInputElement) { tipInputElement.focus() }
if (!isConfirming) {
setActiveTab(TAB_BOOST);
}
setPresetTipAmount(amount);
setUseCustomTip(false);
}}
className={classnames('button-toggle', { 'button-toggle--active': activeTab === TAB_BOOST })}
/>
))}
<Button
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': useCustomTip, // set as active
})}
icon={iconToUse}
label={__('Custom')}
onClick={() => setUseCustomTip(true)}
// disabled if it's receive fiat and there is no card or creator can't receive tips
disabled={activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)}
/>
{DEFAULT_TIP_AMOUNTS.some((val) => val > balance) && activeTab !== TAB_FIAT && (
<Button
button="secondary"
className="button-toggle-group-action"
icon={ICONS.BUY}
title={__('Buy or swap more LBRY Credits')}
navigate={`/$/${PAGES.BUY}`}
/>
)}
</div>
{useCustomTip && (
<div className="section">
<FormField
autoFocus
name="tip-input"
label={
<React.Fragment>
{__('Custom support amount')}{' '}
{activeTab !== TAB_FIAT ? (
<I18nMessage
tokens={{ lbc_balance: <CreditAmount precision={4} amount={balance} showLBC={false} /> }}
>
(%lbc_balance% Credits available)
</I18nMessage>
) : (
'in USD'
)}
</React.Fragment>
}
error={tipError}
min="0"
step="any"
type="number"
style={{
width: activeTab === TAB_FIAT ? '99px' : '160px',
}}
placeholder="1.23"
value={customTipAmount}
onChange={(event) => handleCustomPriceChange(event)}
/>
</div>
)}
{/* short explainer under the button */}
<div className="section__subtitle">
{explainerText}
{/* {activeTab === TAB_FIAT && !hasCardSaved && <Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add A Card')} button="link" />} */}
{<Button label={__('Learn more')} button="link" href="https://lbry.com/faq/tipping" />}
{/* send tip/boost button */}
<div className="section__actions">
<Button
autoFocus
icon={isSupport ? ICONS.TRENDING : ICONS.SUPPORT}
button="primary"
type="submit"
disabled={
fetchingChannels ||
isPending ||
tipError ||
!tipAmount ||
(activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
}
label={buildButtonText()}
/>
{fetchingChannels && <span className="help">{__('Loading your channels...')}</span>}
</div>
</React.Fragment>
}
actions={
// confirmation modal, allow user to confirm or cancel transaction
isConfirming ? (
<>
<div className="section section--padded card--inline confirm__wrapper">
<div className="section">
<div className="confirm__label">{__('To --[the tip recipient]--')}</div>
<div className="confirm__value">{channelName || title}</div>
<div className="confirm__label">{__('From --[the tip sender]--')}</div>
<div className="confirm__value">
{activeChannelClaim && !incognito ? activeChannelClaim.name : __('Anonymous')}
</div>
<div className="confirm__label">{setConfirmLabel()}</div>
<div className="confirm__value">
{activeTab === TAB_FIAT ? <p>$ {tipAmount}</p> : <LbcSymbol postfix={tipAmount} size={22} />}
</div>
</div>
</div>
{activeTab !== TAB_FIAT ? (
<WalletSpendableBalanceHelp />
) : !canReceiveFiatTip ? (
<div className="help">{__('Only creators that verify cash accounts can receive tips')}</div>
) : (
<div className="help">{__('The payment will be made from your saved card')}</div>
)}
</>
) : (
// if it's LBC and there is no balance, you can prompt to purchase LBC
<Card
title={
<I18nMessage tokens={{ lbc: <LbcSymbol size={22} /> }}>Supporting content requires %lbc%</I18nMessage>
}
subtitle={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>
With %lbc%, you can send tips to your favorite creators, or help boost their content for more people
to see.
</I18nMessage>
}
actions={
<div className="section__actions">
<Button
autoFocus
onClick={handleSubmit}
icon={ICONS.REWARDS}
button="primary"
disabled={isPending}
label={__('Confirm')}
label={__('Earn Rewards')}
navigate={`/$/${PAGES.REWARDS}`}
/>
<Button button="link" label={__('Cancel')} onClick={() => setIsConfirming(false)} />
</div>
</>
) : (
<>
<div className="section">
<ChannelSelector />
</div>
{activeTab === TAB_FIAT && !hasCardSaved && (
<h3 className="add-card-prompt">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card')} button="link" />
{' '}{__('To Tip Creators')}
</h3>
)}
{/* section to pick tip/boost amount */}
<div className="section">
{DEFAULT_TIP_AMOUNTS.map((amount) => (
<Button
key={amount}
disabled={shouldDisableAmountSelector(amount)}
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': tipAmount === amount && !useCustomTip,
'button-toggle--disabled': amount > balance,
})}
label={amount}
icon={iconToUse}
onClick={() => {
setPresetTipAmount(amount);
setUseCustomTip(false);
}}
/>
))}
<Button
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': useCustomTip, // set as active
})}
icon={iconToUse}
label={__('Custom')}
onClick={() => setUseCustomTip(true)}
// disabled if it's receive fiat and there is no card or creator can't receive tips
disabled={activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip)}
icon={ICONS.BUY}
button="secondary"
label={__('Buy/Swap Credits')}
navigate={`/$/${PAGES.BUY}`}
/>
{DEFAULT_TIP_AMOUNTS.some((val) => val > balance) && activeTab !== TAB_FIAT && (
<Button
button="secondary"
className="button-toggle-group-action"
icon={ICONS.BUY}
title={__('Buy or swap more LBRY Credits')}
navigate={`/$/${PAGES.BUY}`}
/>
)}
<Button button="link" label={__('Nevermind')} onClick={closeModal} />
</div>
{useCustomTip && (
<div className="section">
<FormField
autoFocus
name="tip-input"
label={
<React.Fragment>
{__('Custom support amount')}{' '}
{activeTab !== TAB_FIAT ? (
<I18nMessage
tokens={{ lbc_balance: <CreditAmount precision={4} amount={balance} showLBC={false} /> }}
>
(%lbc_balance% Credits available)
</I18nMessage>
) : (
'in USD'
)}
</React.Fragment>
}
className="form-field--price-amount"
error={tipError}
min="0"
step="any"
type="number"
placeholder="1.23"
value={customTipAmount}
onChange={(event) => handleCustomPriceChange(event)}
/>
</div>
)}
{/* send tip/boost button */}
<div className="section__actions">
<Button
autoFocus
icon={isSupport ? ICONS.TRENDING : ICONS.SUPPORT}
button="primary"
type="submit"
disabled={
fetchingChannels ||
isPending ||
tipError ||
!tipAmount ||
(activeTab === TAB_FIAT && (!hasCardSaved || !canReceiveFiatTip))
}
label={buildButtonText()}
/>
{fetchingChannels && <span className="help">{__('Loading your channels...')}</span>}
</div>
{activeTab !== TAB_FIAT ? (
<WalletSpendableBalanceHelp />
) : !canReceiveFiatTip ? (
<div className="help">{__('Only select creators can receive tips at this time')}</div>
) : (
<div className="help">{__('The payment will be made from your saved card')}</div>
)}
</>
)
}
/>
)}
}
/>
)
}
/>
</Form>
);
}

View file

@ -11,14 +11,8 @@ import classnames from 'classnames';
import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
import { Lbryio } from 'lbryinc';
import { STRIPE_PUBLIC_KEY } from 'config';
let stripeEnvironment = 'test';
// if the key contains pk_live it's a live key
// update the environment for the calls to the backend to indicate which environment to hit
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
stripeEnvironment = 'live';
}
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100];
@ -76,45 +70,49 @@ function WalletTipAmountSelector(props: Props) {
// check if creator has a payment method saved
React.useEffect(() => {
Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
).then((customerStatusResponse) => {
const defaultPaymentMethodId =
customerStatusResponse.Customer &&
customerStatusResponse.Customer.invoice_settings &&
customerStatusResponse.Customer.invoice_settings.default_payment_method &&
customerStatusResponse.Customer.invoice_settings.default_payment_method.id;
if (stripeEnvironment) {
Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
).then((customerStatusResponse) => {
const defaultPaymentMethodId =
customerStatusResponse.Customer &&
customerStatusResponse.Customer.invoice_settings &&
customerStatusResponse.Customer.invoice_settings.default_payment_method &&
customerStatusResponse.Customer.invoice_settings.default_payment_method.id;
setHasSavedCard(Boolean(defaultPaymentMethodId));
});
}, []);
setHasSavedCard(Boolean(defaultPaymentMethodId));
});
}
}, [stripeEnvironment]);
//
React.useEffect(() => {
Lbryio.call(
'account',
'check',
{
channel_claim_id: channelClaimId,
channel_name: tipChannelName,
environment: stripeEnvironment,
},
'post'
)
.then((accountCheckResponse) => {
if (accountCheckResponse === true && canReceiveFiatTip !== true) {
setCanReceiveFiatTip(true);
}
})
.catch(function (error) {
// console.log(error);
});
}, []);
if (stripeEnvironment) {
Lbryio.call(
'account',
'check',
{
channel_claim_id: channelClaimId,
channel_name: tipChannelName,
environment: stripeEnvironment,
},
'post'
)
.then((accountCheckResponse) => {
if (accountCheckResponse === true && canReceiveFiatTip !== true) {
setCanReceiveFiatTip(true);
}
})
.catch(function (error) {
// console.log(error);
});
}
}, [stripeEnvironment]);
React.useEffect(() => {
// setHasSavedCard(false);
@ -214,7 +212,7 @@ function WalletTipAmountSelector(props: Props) {
<div className="help">
<span className="help--spendable">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card ')} button="link" /> To{' '}
{__(' Tip Creators')}
{__('Tip Creators')}
</span>
</div>
</>
@ -224,7 +222,7 @@ function WalletTipAmountSelector(props: Props) {
{useCustomTip && activeTab === TAB_FIAT && hasCardSaved && !canReceiveFiatTip && (
<>
<div className="help">
<span className="help--spendable">Only select creators can receive tips at this time</span>
<span className="help--spendable">Only creators that verify cash accounts can receive tips</span>
</div>
</>
)}
@ -263,7 +261,6 @@ function WalletTipAmountSelector(props: Props) {
// </div>
// </>
}
className="form-field--price-amount"
error={tipError}
min="0"
step="any"
@ -283,7 +280,7 @@ function WalletTipAmountSelector(props: Props) {
<div className="help">
<span className="help--spendable">
<Button navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`} label={__('Add a Card ')} button="link" /> To{' '}
{__(' Tip Creators')}
{__('Tip Creators')}
</span>
</div>
</>
@ -293,7 +290,7 @@ function WalletTipAmountSelector(props: Props) {
{!useCustomTip && activeTab === TAB_FIAT && hasCardSaved && !canReceiveFiatTip && (
<>
<div className="help">
<span className="help--spendable">Only select creators can receive tips at this time</span>
<span className="help--spendable">Only creators that verify cash accounts can receive tips</span>
</div>
</>
)}

View file

@ -165,3 +165,4 @@ export const GLOBE = 'globe';
export const RSS = 'rss';
export const STAR = 'star';
export const MUSIC = 'MusicCategory';
export const BADGE_MOD = 'BadgeMod';

2
ui/constants/stripe.js Normal file
View file

@ -0,0 +1,2 @@
export const TEST = 'test';
export const LIVE = 'live';

View file

@ -5,11 +5,13 @@ import React, { useEffect } from 'react';
/**
* Helper React hook for lazy loading images
* @param elementRef - A React useRef instance to the element to lazy load.
* @param backgroundFallback
* @param yOffsetPx - Number of pixels from the viewport to start loading.
* @param {Array<>} [deps=[]] - The dependencies this lazy-load is reliant on.
*/
export default function useLazyLoading(
elementRef: { current: ?ElementRef<any> },
backgroundFallback: string = '',
yOffsetPx: number = 500,
deps: Array<any> = []
) {
@ -42,11 +44,19 @@ export default function useLazyLoading(
// $FlowFixMe
target.src = target.dataset.src;
setSrcLoaded(true);
// No fallback handling here (clients have access to 'onerror' on the image ref).
return;
}
// useful for lazy loading background images on divs
if (target.dataset.backgroundImage) {
if (backgroundFallback) {
const tmpImage = new Image();
tmpImage.onerror = () => {
target.style.backgroundImage = `url(${backgroundFallback})`;
};
tmpImage.src = target.dataset.backgroundImage;
}
target.style.backgroundImage = `url(${target.dataset.backgroundImage})`;
}
}

View file

@ -25,7 +25,7 @@ export default function useLighthouse(
let isSubscribed = true;
lighthouse
.search(throttledQuery)
.then((results) => {
.then(({ body: results }) => {
if (isSubscribed) {
setResults(
results.map((result) => `lbry://${result.name}#${result.claimId}`).filter((uri) => isURIValid(uri))

View file

@ -5,43 +5,19 @@ import Card from 'component/common/card';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
import { Lbryio } from 'lbryinc';
import { STRIPE_PUBLIC_KEY } from 'config';
let stripeEnvironment = 'test';
// if the key contains pk_live it's a live key
// update the environment for the calls to the backend to indicate which environment to hit
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
stripeEnvironment = 'live';
}
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
type Props = {
closeModal: () => void,
abandonTxo: (Txo, () => void) => void,
abandonClaim: (string, number, ?() => void) => void,
tx: Txo,
claim: GenericClaim,
cb: () => void,
doResolveUri: (string) => void,
uri: string,
paymentMethodId: string,
setAsConfirmingCard: () => void,
setAsConfirmingCard: () => void, // ?
};
export default function ModalRevokeClaim(props: Props) {
var that = this;
console.log(that);
console.log(props);
const { closeModal, uri, paymentMethodId, setAsConfirmingCard } = props;
console.log(uri);
console.log(setAsConfirmingCard);
export default function ModalRemoveCard(props: Props) {
const { closeModal, paymentMethodId } = props;
function removeCard() {
console.log(paymentMethodId);
Lbryio.call(
'customer',
'detach',
@ -51,11 +27,9 @@ export default function ModalRevokeClaim(props: Props) {
},
'post'
).then((removeCardResponse) => {
console.log(removeCardResponse);
// TODO: add toast here
// closeModal();
// Is there a better way to handle this? Why reload?
window.location.reload();
});
}
@ -63,15 +37,14 @@ export default function ModalRevokeClaim(props: Props) {
return (
<Modal ariaHideApp={false} isOpen contentLabel={'hello'} type="card" onAborted={closeModal}>
<Card
title={'Confirm Remove Card'}
// body={getMsgBody(type, isSupport, name)}
title={__('Confirm Remove Card')}
actions={
<div className="section__actions">
<Button
className="stripe__confirm-remove-card"
button="secondary"
icon={ICONS.DELETE}
label={'Remove Card'}
label={__('Remove Card')}
onClick={removeCard}
/>
<Button button="link" label={__('Cancel')} onClick={closeModal} />

View file

@ -1,4 +1,5 @@
// @flow
import { SITE_NAME } from 'config';
import React, { useEffect } from 'react';
import classnames from 'classnames';
import FileRender from 'component/fileRender';
@ -110,7 +111,7 @@ const EmbedWrapperPage = (props: Props) => {
<div>
<h1>{__('Paid content cannot be embedded.')}</h1>
<div className="section__actions--centered">
<Button label={__('Watch on lbry.tv')} button="primary" href={contentLink} />
<Button label={__('Watch on %SITE_NAME%', { SITE_NAME })} button="primary" href={contentLink} />
</div>
</div>
)}

View file

@ -12,7 +12,6 @@ import FileRenderDownload from 'component/fileRenderDownload';
import RecommendedContent from 'component/recommendedContent';
import CollectionContent from 'component/collectionContentSidebar';
import CommentsList from 'component/commentsList';
import { Redirect } from 'react-router';
import Button from 'component/button';
import I18nMessage from 'component/i18nMessage';
import Empty from 'component/common/empty';
@ -145,10 +144,6 @@ function FilePage(props: Props) {
);
}
if (!claimIsMine && isLivestream) {
return <Redirect to={`/$/${PAGES.LIVESTREAM}`} />;
}
if (obscureNsfw && isMature) {
return (
<Page className="file-page" filePage isMarkdown={isMarkdown}>

View file

@ -11,7 +11,7 @@ type Props = {
export default function LivestreamCurrentPage(props: Props) {
const { user } = props;
const canView = user && user.global_mod;
const canView = process.env.ENABLE_WIP_FEATURES || (user && user.global_mod);
return (
<Page>

View file

@ -3,7 +3,7 @@ import { withRouter } from 'react-router';
import { doSearch } from 'redux/actions/search';
import {
selectIsSearching,
makeSelectSearchUris,
makeSelectSearchUrisForQuery,
selectSearchOptions,
makeSelectHasReachedMaxResultsLength,
} from 'redux/selectors/search';
@ -28,7 +28,7 @@ const select = (state, props) => {
};
const query = getSearchQueryString(urlQuery, searchOptions);
const uris = makeSelectSearchUris(query)(state);
const uris = makeSelectSearchUrisForQuery(query)(state);
const hasReachedMaxResultsLength = makeSelectHasReachedMaxResultsLength(query)(state);
return {

View file

@ -20,6 +20,7 @@ import { SIMPLE_SITE } from 'config';
import homepages from 'homepages';
import { Lbryio } from 'lbryinc';
import Yrbl from 'component/yrbl';
import { getStripeEnvironment } from 'util/stripe';
type Price = {
currency: string,
@ -208,44 +209,6 @@ class SettingsPage extends React.PureComponent<Props, State> {
}}
className="card-stack"
>
{/* @if TARGET='web' */}
{user && user.fiat_enabled && (
<Card
title={__('Bank Accounts')}
subtitle={__('Connect a bank account to receive tips and compensation in your local currency')}
actions={
<div className="section__actions">
<Button
button="secondary"
label={__('Manage')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.SETTINGS_STRIPE_ACCOUNT}`}
/>
</div>
}
/>
)}
{/* @endif */}
{/* @if TARGET='web' */}
{isAuthenticated && (
<Card
title={__('Payment Methods')}
subtitle={__('Add a credit card to tip creators in their local currency')}
actions={
<div className="section__actions">
<Button
button="secondary"
label={__('Manage')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`}
/>
</div>
}
/>
)}
{/* @endif */}
<Card title={__('Language')} actions={<SettingLanguage />} />
{homepages && Object.keys(homepages).length > 1 && (
<Card title={__('Homepage')} actions={<HomepageSelector />} />
@ -488,6 +451,44 @@ class SettingsPage extends React.PureComponent<Props, State> {
/>
{/* @endif */}
{/* @if TARGET='web' */}
{user && getStripeEnvironment() && (
<Card
title={__('Bank Accounts')}
subtitle={__('Connect a bank account to receive tips and compensation in your local currency')}
actions={
<div className="section__actions">
<Button
button="secondary"
label={__('Manage')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.SETTINGS_STRIPE_ACCOUNT}`}
/>
</div>
}
/>
)}
{/* @endif */}
{/* @if TARGET='web' */}
{isAuthenticated && getStripeEnvironment() && (
<Card
title={__('Payment Methods')}
subtitle={__('Add a credit card to tip creators in their local currency')}
actions={
<div className="section__actions">
<Button
button="secondary"
label={__('Manage')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.SETTINGS_STRIPE_CARD}`}
/>
</div>
}
/>
)}
{/* @endif */}
{(isAuthenticated || !IS_WEB) && (
<>
<Card

View file

@ -5,6 +5,7 @@ import { FormField, FormFieldPrice } from 'component/common/form';
import Button from 'component/button';
import I18nMessage from 'component/i18nMessage';
import Page from 'component/page';
import SettingCommentsServer from 'component/settingCommentsServer';
import SettingWalletServer from 'component/settingWalletServer';
import SettingAutoLaunch from 'component/settingAutoLaunch';
import SettingClosingBehavior from 'component/settingClosingBehavior';
@ -507,6 +508,10 @@ class SettingsAdvancedPage extends React.PureComponent<Props, State> {
/>
)}
{/* @if TARGET='app' */}
<Card title={__('Comments server')} actions={<SettingCommentsServer />} />
{/* @endif */}
<Card title={__('Upload settings')} actions={<PublishSettings />} />
{/* @if TARGET='app' */}

View file

@ -29,7 +29,7 @@ const select = (state) => ({
const perform = (dispatch) => ({
commentBlockWords: (channelClaim, words) => dispatch(doCommentBlockWords(channelClaim, words)),
commentUnblockWords: (channelClaim, words) => dispatch(doCommentUnblockWords(channelClaim, words)),
fetchCreatorSettings: (channelClaimIds) => dispatch(doFetchCreatorSettings(channelClaimIds)),
fetchCreatorSettings: (channelClaimId) => dispatch(doFetchCreatorSettings(channelClaimId)),
updateCreatorSettings: (channelClaim, settings) => dispatch(doUpdateCreatorSettings(channelClaim, settings)),
commentModAddDelegate: (modChanId, modChanName, creatorChannelClaim) =>
dispatch(doCommentModAddDelegate(modChanId, modChanName, creatorChannelClaim)),

View file

@ -11,11 +11,17 @@ import LbcSymbol from 'component/common/lbc-symbol';
import I18nMessage from 'component/i18nMessage';
import { isNameValid, parseURI } from 'lbry-redux';
import ClaimPreview from 'component/claimPreview';
import debounce from 'util/debounce';
import { getUriForSearchTerm } from 'util/search';
const DEBOUNCE_REFRESH_MS = 1000;
const FEATURE_IS_READY = false;
const LBC_MAX = 21000000;
const LBC_MIN = 0;
const LBC_STEP = 1.0;
// ****************************************************************************
// ****************************************************************************
type Props = {
activeChannelClaim: ChannelClaim,
@ -28,7 +34,7 @@ type Props = {
commentModAddDelegate: (string, string, ChannelClaim) => void,
commentModRemoveDelegate: (string, string, ChannelClaim) => void,
commentModListDelegates: (ChannelClaim) => void,
fetchCreatorSettings: (Array<string>) => void,
fetchCreatorSettings: (channelId: string) => void,
updateCreatorSettings: (ChannelClaim, PerChannelSettings) => void,
doToast: ({ message: string }) => void,
};
@ -54,26 +60,28 @@ export default function SettingsCreatorPage(props: Props) {
const [moderatorSearchTerm, setModeratorSearchTerm] = React.useState('');
const [moderatorSearchError, setModeratorSearchError] = React.useState('');
const [moderatorSearchClaimUri, setModeratorSearchClaimUri] = React.useState('');
const [minTipAmountComment, setMinTipAmountComment] = React.useState(0);
const [minTipAmountSuperChat, setMinTipAmountSuperChat] = React.useState(0);
const [slowModeMinGap, setSlowModeMinGap] = React.useState(0);
const [minTip, setMinTip] = React.useState(0);
const [minSuper, setMinSuper] = React.useState(0);
const [slowModeMin, setSlowModeMin] = React.useState(0);
const [lastUpdated, setLastUpdated] = React.useState(1);
function settingsToStates(settings: PerChannelSettings) {
if (settings.comments_enabled !== undefined) {
setCommentsEnabled(settings.comments_enabled);
}
if (settings.min_tip_amount_comment !== undefined) {
setMinTipAmountComment(settings.min_tip_amount_comment);
}
if (settings.min_tip_amount_super_chat !== undefined) {
setMinTipAmountSuperChat(settings.min_tip_amount_super_chat);
}
if (settings.slow_mode_min_gap !== undefined) {
setSlowModeMinGap(settings.slow_mode_min_gap);
}
if (settings.words) {
const tagArray = Array.from(new Set(settings.words));
const pushSlowModeMinDebounced = React.useMemo(() => debounce(pushSlowModeMin, 1000), []);
const pushMinTipDebounced = React.useMemo(() => debounce(pushMinTip, 1000), []);
const pushMinSuperDebounced = React.useMemo(() => debounce(pushMinSuper, 1000), []);
// **************************************************************************
// **************************************************************************
/**
* Updates corresponding GUI states with the given PerChannelSettings values.
*
* @param settings
* @param fullSync If true, update all states and consider 'undefined' settings as "cleared/false";
* if false, only update defined settings.
*/
function settingsToStates(settings: PerChannelSettings, fullSync: boolean) {
const doSetMutedWordTags = (words: Array<string>) => {
const tagArray = Array.from(new Set(words));
setMutedWordTags(
tagArray
.filter((t) => t !== '')
@ -81,15 +89,51 @@ export default function SettingsCreatorPage(props: Props) {
return { name: x };
})
);
};
if (fullSync) {
setCommentsEnabled(settings.comments_enabled || false);
setMinTip(settings.min_tip_amount_comment || 0);
setMinSuper(settings.min_tip_amount_super_chat || 0);
setSlowModeMin(settings.slow_mode_min_gap || 0);
doSetMutedWordTags(settings.words || []);
} else {
if (settings.comments_enabled !== undefined) {
setCommentsEnabled(settings.comments_enabled);
}
if (settings.min_tip_amount_comment !== undefined) {
setMinTip(settings.min_tip_amount_comment);
}
if (settings.min_tip_amount_super_chat !== undefined) {
setMinSuper(settings.min_tip_amount_super_chat);
}
if (settings.slow_mode_min_gap !== undefined) {
setSlowModeMin(settings.slow_mode_min_gap);
}
if (settings.words) {
doSetMutedWordTags(settings.words);
}
}
}
function setSettings(newSettings: PerChannelSettings) {
settingsToStates(newSettings);
settingsToStates(newSettings, false);
updateCreatorSettings(activeChannelClaim, newSettings);
setLastUpdated(Date.now());
}
function pushSlowModeMin(value: number, activeChannelClaim: ChannelClaim) {
updateCreatorSettings(activeChannelClaim, { slow_mode_min_gap: value });
}
function pushMinTip(value: number, activeChannelClaim: ChannelClaim) {
updateCreatorSettings(activeChannelClaim, { min_tip_amount_comment: value });
}
function pushMinSuper(value: number, activeChannelClaim: ChannelClaim) {
updateCreatorSettings(activeChannelClaim, { min_tip_amount_super_chat: value });
}
function addMutedWords(newTags: Array<Tag>) {
const validatedNewTags = [];
newTags.forEach((newTag) => {
@ -153,6 +197,9 @@ export default function SettingsCreatorPage(props: Props) {
}
}
// **************************************************************************
// **************************************************************************
// 'moderatorSearchTerm' to 'moderatorSearchClaimUri'
React.useEffect(() => {
if (!moderatorSearchTerm) {
@ -211,20 +258,23 @@ export default function SettingsCreatorPage(props: Props) {
if (activeChannelClaim && settingsByChannelId && settingsByChannelId[activeChannelClaim.claim_id]) {
const channelSettings = settingsByChannelId[activeChannelClaim.claim_id];
settingsToStates(channelSettings);
settingsToStates(channelSettings, true);
}
}, [activeChannelClaim, settingsByChannelId, lastUpdated]);
// Re-sync list, mainly to correct any invalid settings.
// Re-sync list on first idle time; mainly to correct any invalid settings.
React.useEffect(() => {
if (lastUpdated && activeChannelClaim) {
const timer = setTimeout(() => {
fetchCreatorSettings([activeChannelClaim.claim_id]);
fetchCreatorSettings(activeChannelClaim.claim_id);
}, DEBOUNCE_REFRESH_MS);
return () => clearTimeout(timer);
}
}, [lastUpdated, activeChannelClaim, fetchCreatorSettings]);
// **************************************************************************
// **************************************************************************
const isBusy =
!activeChannelClaim || !settingsByChannelId || settingsByChannelId[activeChannelClaim.claim_id] === undefined;
const isDisabled =
@ -272,8 +322,13 @@ export default function SettingsCreatorPage(props: Props) {
step={1}
type="number"
placeholder="1"
value={slowModeMinGap}
onChange={(e) => setSettings({ slow_mode_min_gap: parseInt(e.target.value) })}
value={slowModeMin}
onChange={(e) => {
const value = parseInt(e.target.value);
setSlowModeMin(value);
pushSlowModeMinDebounced(value, activeChannelClaim);
}}
onBlur={() => setLastUpdated(Date.now())}
/>
</>
}
@ -297,47 +352,70 @@ export default function SettingsCreatorPage(props: Props) {
</div>
}
/>
{FEATURE_IS_READY && (
<Card
title={__('Tip')}
actions={
<>
<FormField
name="min_tip_amount_comment"
label={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for comments</I18nMessage>
<Card
title={__('Tip')}
actions={
<>
<FormField
name="min_tip_amount_comment"
label={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for comments</I18nMessage>
}
helper={__(
'Enabling a minimum amount to comment will force all comments, including livestreams, to have tips associated with them. This can help prevent spam.'
)}
className="form-field--price-amount"
max={LBC_MAX}
min={LBC_MIN}
step={LBC_STEP}
type="number"
placeholder="1"
value={minTip}
onChange={(e) => {
const newMinTip = parseFloat(e.target.value);
setMinTip(newMinTip);
pushMinTipDebounced(newMinTip, activeChannelClaim);
if (newMinTip !== 0 && minSuper !== 0) {
setMinSuper(0);
pushMinSuperDebounced(0, activeChannelClaim);
}
helper={__(
'Enabling a minimum amount to comment will force all comments, including livestreams, to have tips associated with them. This can help prevent spam.'
)}
className="form-field--price-amount"
min={0}
step="any"
type="number"
placeholder="1"
value={minTipAmountComment}
onChange={(e) => setSettings({ min_tip_amount_comment: parseFloat(e.target.value) })}
/>
<FormField
name="min_tip_amount_super_chat"
label={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for hyperchats</I18nMessage>
}
helper={__(
'Enabling a minimum amount to hyperchat will force all TIPPED comments to have this value in order to be shown. This still allows regular comments to be posted.'
)}
className="form-field--price-amount"
min={0}
step="any"
type="number"
placeholder="1"
value={minTipAmountSuperChat}
onChange={(e) => setSettings({ min_tip_amount_super_chat: parseFloat(e.target.value) })}
/>
</>
}
/>
)}
}}
onBlur={() => setLastUpdated(Date.now())}
/>
<FormField
name="min_tip_amount_super_chat"
label={
<I18nMessage tokens={{ lbc: <LbcSymbol /> }}>Minimum %lbc% tip amount for hyperchats</I18nMessage>
}
helper={
<>
{__(
'Enabling a minimum amount to hyperchat will force all TIPPED comments to have this value in order to be shown. This still allows regular comments to be posted.'
)}
{minTip !== 0 && (
<p className="help--inline">
<em>{__('(This settings is not applicable if all comments require a tip.)')}</em>
</p>
)}
</>
}
className="form-field--price-amount"
min={0}
step="any"
type="number"
placeholder="1"
value={minSuper}
disabled={minTip !== 0}
onChange={(e) => {
const newMinSuper = parseFloat(e.target.value);
setMinSuper(newMinSuper);
pushMinSuperDebounced(newMinSuper, activeChannelClaim);
}}
onBlur={() => setLastUpdated(Date.now())}
/>
</>
}
/>
<Card
title={__('Delegation')}
className="card--enable-overflow"

View file

@ -1,13 +1,12 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import StripeAccountConnection from './view';
import { selectUser } from 'redux/selectors/user';
import { doToast } from 'redux/actions/notifications';
// function that receives state parameter and returns object of functions that accept state
const select = (state) => ({
user: selectUser(state),
const select = (state) => ({});
const perform = (dispatch) => ({
doToast: (options) => dispatch(doToast(options)),
});
// const perform = (dispatch) => ({});
export default withRouter(connect(select)(StripeAccountConnection));
export default withRouter(connect(select, perform)(StripeAccountConnection));

View file

@ -1,22 +1,18 @@
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import React from 'react';
import Button from 'component/button';
import Card from 'component/common/card';
import Page from 'component/page';
import { Lbryio } from 'lbryinc';
import { URL, WEBPACK_WEB_PORT, STRIPE_PUBLIC_KEY } from 'config';
import moment from 'moment';
import { URL, WEBPACK_WEB_PORT } from 'config';
import { getStripeEnvironment } from 'util/stripe';
const isDev = process.env.NODE_ENV !== 'production';
let stripeEnvironment = 'test';
// if the key contains pk_live it's a live key
// update the environment for the calls to the backend to indicate which environment to hit
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
stripeEnvironment = 'live';
}
let stripeEnvironment = getStripeEnvironment();
let successStripeRedirectUrl, failureStripeRedirectUrl;
let successEndpoint = '/$/settings/tip_account';
let failureEndpoint = '/$/settings/tip_account';
@ -30,7 +26,8 @@ if (isDev) {
type Props = {
source: string,
user: User,
doOpenModal: (string, {}) => void,
doToast: ({ message: string }) => void,
};
type State = {
@ -38,13 +35,13 @@ type State = {
loading: boolean,
content: ?string,
stripeConnectionUrl: string,
// alreadyUpdated: boolean,
accountConfirmed: boolean,
accountPendingConfirmation: boolean,
accountNotConfirmedButReceivedTips: boolean,
unpaidBalance: number,
pageTitle: string,
accountTransactions: any, // define this type
stillRequiringVerification: boolean,
accountTransactions: any,
};
class StripeAccountConnection extends React.Component<Props, State> {
@ -60,16 +57,13 @@ class StripeAccountConnection extends React.Component<Props, State> {
unpaidBalance: 0,
stripeConnectionUrl: '',
pageTitle: 'Add Payout Method',
stillRequiringVerification: false,
accountTransactions: [],
// alreadyUpdated: false,
};
}
componentDidMount() {
const { user } = this.props;
// $FlowFixMe
this.experimentalUiEnabled = user && user.experimental_ui;
let doToast = this.props.doToast;
var that = this;
@ -127,10 +121,8 @@ class StripeAccountConnection extends React.Component<Props, State> {
).then((accountListResponse: any) => {
// TODO type this
that.setState({
accountTransactions: accountListResponse,
accountTransactions: accountListResponse.reverse(),
});
console.log(accountListResponse);
});
}
@ -138,9 +130,25 @@ class StripeAccountConnection extends React.Component<Props, State> {
if (accountStatusResponse.charges_enabled) {
// account has already been confirmed
that.setState({
const eventuallyDueInformation = accountStatusResponse.account_info.requirements.eventually_due;
const currentlyDueInformation = accountStatusResponse.account_info.requirements.currently_due;
let objectToUpdateState = {
accountConfirmed: true,
});
stillRequiringVerification: false,
};
if (
(eventuallyDueInformation && eventuallyDueInformation.length) ||
(currentlyDueInformation && currentlyDueInformation.length)
) {
objectToUpdateState.stillRequiringVerification = true;
getAndSetAccountLink(false);
}
that.setState(objectToUpdateState);
// user has not confirmed an account but have received payments
} else if (accountStatusResponse.total_received_unpaid > 0) {
that.setState({
@ -165,6 +173,9 @@ class StripeAccountConnection extends React.Component<Props, State> {
// get stripe link and set it on the frontend
getAndSetAccountLink(true);
} else {
// probably an error from stripe
const displayString = __('There was an error getting your account setup, please try again later');
doToast({ message: displayString, isError: true });
// not an error from Beamer, throw it
throw new Error(error);
}
@ -179,35 +190,79 @@ class StripeAccountConnection extends React.Component<Props, State> {
unpaidBalance,
accountNotConfirmedButReceivedTips,
pageTitle,
accountTransactions,
stillRequiringVerification,
} = this.state;
const { user } = this.props;
if (user.fiat_enabled) {
return (
<Page backout={{ title: pageTitle, backLabel: __('Done') }} noFooter noSideNavigation>
<Card
title={<div className="table__header-text">{__('Connect a bank account')}</div>}
isBodyList
body={
<div>
{/* show while waiting for account status */}
{!accountConfirmed && !accountPendingConfirmation && !accountNotConfirmedButReceivedTips && (
<div className="card__body-actions">
return (
<Page backout={{ title: pageTitle, backLabel: __('Done') }} noFooter noSideNavigation>
<Card
title={<div className="table__header-text">{__('Connect a bank account')}</div>}
isBodyList
body={
<div>
{/* show while waiting for account status */}
{!accountConfirmed && !accountPendingConfirmation && !accountNotConfirmedButReceivedTips && (
<div className="card__body-actions">
<div>
<div>
<div>
<h3>{__('Getting your bank account connection status...')}</h3>
</div>
<h3>{__('Getting your bank account connection status...')}</h3>
</div>
</div>
)}
{/* user has yet to complete their integration */}
{!accountConfirmed && accountPendingConfirmation && (
<div className="card__body-actions">
</div>
)}
{/* user has yet to complete their integration */}
{!accountConfirmed && accountPendingConfirmation && (
<div className="card__body-actions">
<div>
<div>
<h3>{__('Connect your bank account to Odysee to receive donations directly from users')}</h3>
</div>
<div className="section__actions">
<a href={stripeConnectionUrl}>
<Button button="secondary" label={__('Connect your bank account')} icon={ICONS.FINANCE} />
</a>
</div>
</div>
</div>
)}
{/* user has completed their integration */}
{accountConfirmed && (
<div className="card__body-actions">
<div>
<div>
<h3>{__('Congratulations! Your account has been connected with Odysee.')}</h3>
{stillRequiringVerification && (
<>
<h3 style={{ marginTop: '10px' }}>
Although your account is connected it still requires verification to begin receiving tips.
</h3>
<h3 style={{ marginTop: '10px' }}>
Please use the button below to complete your verification process and enable tipping for
your account.
</h3>
</>
)}
</div>
</div>
</div>
)}
{/* TODO: hopefully we won't be using this anymore and can remove it */}
{accountNotConfirmedButReceivedTips && (
<div className="card__body-actions">
<div>
<div>
<h3>{__('Congratulations, you have already begun receiving tips on Odysee!')}</h3>
<div>
<h3>{__('Connect your bank account to Odysee to receive donations directly from users')}</h3>
<br />
<h3>
{__('Your pending account balance is $%balance% USD.', { balance: unpaidBalance / 100 })}
</h3>
</div>
<br />
<div>
<h3>
{__('Connect your bank account to be able to cash your pending balance out to your account.')}
</h3>
</div>
<div className="section__actions">
<a href={stripeConnectionUrl}>
@ -216,127 +271,33 @@ class StripeAccountConnection extends React.Component<Props, State> {
</div>
</div>
</div>
)}
{/* user has completed their integration */}
{accountConfirmed && (
<div className="card__body-actions">
<div>
<div>
<h3>{__('Congratulations! Your account has been connected with Odysee.')}</h3>
{unpaidBalance > 0 ? (
<div>
<br />
<h3>
{__('Your pending account balance is $%balance% USD.', { balance: unpaidBalance / 100 })}
</h3>
</div>
) : (
<div>
<br />
<h3>
{__('Your account balance is $0 USD. When you receive a tip you will see it here.')}
</h3>
</div>
)}
</div>
</div>
</div>
)}
{accountNotConfirmedButReceivedTips && (
<div className="card__body-actions">
<div>
<div>
<h3>{__('Congratulations, you have already begun receiving tips on Odysee!')}</h3>
<div>
<br />
<h3>
{__('Your pending account balance is $%balance% USD.', { balance: unpaidBalance / 100 })}
</h3>
</div>
<br />
<div>
<h3>
{__(
'Connect your bank account to be able to cash your pending balance out to your account.'
)}
</h3>
</div>
<div className="section__actions">
<a href={stripeConnectionUrl}>
<Button button="secondary" label={__('Connect your bank account')} icon={ICONS.FINANCE} />
</a>
</div>
</div>
</div>
</div>
)}
</div>
}
/>
<br />
{/* customer already has transactions */}
{accountTransactions && accountTransactions.length > 0 && (
<Card
title={__('Tip History')}
body={
<>
<div className="table__wrapper">
<table className="table table--transactions">
<thead>
<tr>
<th className="date-header">{__('Date')}</th>
<th>{<>{__('Receiving Channel Name')}</>}</th>
<th>{__('Tip Location')}</th>
<th>{__('Amount (USD)')} </th>
<th>{__('Processing Fee')}</th>
<th>{__('Odysee Fee')}</th>
<th>{__('Received Amount')}</th>
</tr>
</thead>
<tbody>
{accountTransactions &&
accountTransactions.reverse().map((transaction) => (
<tr key={transaction.name + transaction.created_at}>
<td>{moment(transaction.created_at).format('LLL')}</td>
<td>
<Button
className="stripe__card-link-text"
navigate={'/' + transaction.channel_name + ':' + transaction.channel_claim_id}
label={transaction.channel_name}
button="link"
/>
</td>
<td>
<Button
className="stripe__card-link-text"
navigate={'/' + transaction.channel_name + ':' + transaction.source_claim_id}
label={
transaction.channel_claim_id === transaction.source_claim_id
? 'Channel Page'
: 'File Page'
}
button="link"
/>
</td>
<td>${transaction.tipped_amount / 100}</td>
<td>${transaction.transaction_fee / 100}</td>
<td>${transaction.application_fee / 100}</td>
<td>${transaction.received_amount / 100}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
}
/>
)}
</Page>
);
} else {
return <></>; // probably null;
}
</div>
)}
</div>
}
actions={
<>
{stillRequiringVerification && (
<Button
button="primary"
label={__('Complete Verification')}
icon={ICONS.SETTINGS}
navigate={stripeConnectionUrl}
className="stripe__complete-verification-button"
/>
)}
<Button
button="secondary"
label={__('View Transactions')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.WALLET}?tab=fiat-payment-history`}
/>
</>
}
/>
<br />
</Page>
);
}
}

View file

@ -5,19 +5,17 @@ import React from 'react';
import Page from 'component/page';
import Card from 'component/common/card';
import { Lbryio } from 'lbryinc';
import { STRIPE_PUBLIC_KEY } from 'config';
import moment from 'moment';
import Plastic from 'react-plastic';
import Button from 'component/button';
import * as ICONS from 'constants/icons';
import * as MODALS from 'constants/modal_types';
import * as PAGES from 'constants/pages';
import { STRIPE_PUBLIC_KEY } from 'config';
import { getStripeEnvironment } from 'util/stripe';
let stripeEnvironment = getStripeEnvironment();
let stripeEnvironment = 'test';
// if the key contains pk_live it's a live key
// update the environment for the calls to the backend to indicate which environment to hit
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
stripeEnvironment = 'live';
}
const APIS_DOWN_ERROR_RESPONSE = __('There was an error from the server, please try again later');
const CARD_SETUP_ERROR_RESPONSE = __('There was an error getting your card setup, please try again later');
// eslint-disable-next-line flowtype/no-types-missing-file-annotation
type Props = {
@ -57,8 +55,6 @@ class SettingsStripeCard extends React.Component<Props, State> {
componentDidMount() {
let that = this;
console.log(this.props);
let doToast = this.props.doToast;
const script = document.createElement('script');
@ -78,123 +74,116 @@ class SettingsStripeCard extends React.Component<Props, State> {
// TODO: fix this, should be a cleaner way
setTimeout(function () {
// check if customer has card setup already
Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
)
.then((customerStatusResponse) => {
// user has a card saved if their defaultPaymentMethod has an id
const defaultPaymentMethod = customerStatusResponse.Customer.invoice_settings.default_payment_method;
let userHasAlreadySetupPayment = Boolean(defaultPaymentMethod && defaultPaymentMethod.id);
if (stripeEnvironment) {
Lbryio.call(
'customer',
'status',
{
environment: stripeEnvironment,
},
'post'
)
.then((customerStatusResponse) => {
// user has a card saved if their defaultPaymentMethod has an id
const defaultPaymentMethod = customerStatusResponse.Customer.invoice_settings.default_payment_method;
let userHasAlreadySetupPayment = Boolean(defaultPaymentMethod && defaultPaymentMethod.id);
// show different frontend if user already has card
if (userHasAlreadySetupPayment) {
let card = customerStatusResponse.PaymentMethods[0].card;
// show different frontend if user already has card
if (userHasAlreadySetupPayment) {
let card = customerStatusResponse.PaymentMethods[0].card;
let customer = customerStatusResponse.Customer;
let customer = customerStatusResponse.Customer;
let topOfDisplay = customer.email.split('@')[0];
let bottomOfDisplay = '@' + customer.email.split('@')[1];
let topOfDisplay = customer.email.split('@')[0];
let bottomOfDisplay = '@' + customer.email.split('@')[1];
console.log(customerStatusResponse.Customer);
let cardDetails = {
brand: card.brand,
expiryYear: card.exp_year,
expiryMonth: card.exp_month,
lastFour: card.last4,
topOfDisplay: topOfDisplay,
bottomOfDisplay: bottomOfDisplay,
};
let cardDetails = {
brand: card.brand,
expiryYear: card.exp_year,
expiryMonth: card.exp_month,
lastFour: card.last4,
topOfDisplay: topOfDisplay,
bottomOfDisplay: bottomOfDisplay,
};
that.setState({
currentFlowStage: 'cardConfirmed',
pageTitle: 'Tip History',
userCardDetails: cardDetails,
paymentMethodId: customerStatusResponse.PaymentMethods[0].id,
});
that.setState({
currentFlowStage: 'cardConfirmed',
pageTitle: 'Tip History',
userCardDetails: cardDetails,
paymentMethodId: customerStatusResponse.PaymentMethods[0].id,
});
// otherwise, prompt them to save a card
} else {
that.setState({
currentFlowStage: 'confirmingCard',
});
// otherwise, prompt them to save a card
} else {
that.setState({
currentFlowStage: 'confirmingCard',
});
// get a payment method secret for frontend
Lbryio.call(
'customer',
'setup',
{
environment: stripeEnvironment,
},
'post'
).then((customerSetupResponse) => {
clientSecret = customerSetupResponse.client_secret;
// get a payment method secret for frontend
// instantiate stripe elements
setupStripe();
});
}
// get customer transactions
Lbryio.call(
'customer',
'setup',
'list',
{
environment: stripeEnvironment,
},
'post'
).then((customerSetupResponse) => {
console.log(customerSetupResponse);
clientSecret = customerSetupResponse.client_secret;
// instantiate stripe elements
setupStripe();
).then((customerTransactionsResponse) => {
that.setState({
customerTransactions: customerTransactionsResponse,
});
});
}
// if the status call fails, either an actual error or need to run setup first
})
.catch(function (error) {
// errorString passed from the API (with a 403 error)
const errorString = 'user as customer is not setup yet';
// get customer transactions
Lbryio.call(
'customer',
'list',
{
environment: stripeEnvironment,
},
'post'
).then((customerTransactionsResponse) => {
that.setState({
customerTransactions: customerTransactionsResponse,
});
// if it's beamer's error indicating the account is not linked yet
if (error.message && error.message.indexOf(errorString) > -1) {
// send them to save a card
that.setState({
currentFlowStage: 'confirmingCard',
});
console.log(customerTransactionsResponse);
// get a payment method secret for frontend
Lbryio.call(
'customer',
'setup',
{
environment: stripeEnvironment,
},
'post'
).then((customerSetupResponse) => {
clientSecret = customerSetupResponse.client_secret;
// instantiate stripe elements
setupStripe();
});
// 500 error from the backend being down
} else if (error === 'internal_apis_down') {
doToast({ message: APIS_DOWN_ERROR_RESPONSE, isError: true });
} else {
// probably an error from stripe
doToast({ message: CARD_SETUP_ERROR_RESPONSE, isError: true });
}
});
// if the status call fails, either an actual error or need to run setup first
})
.catch(function (error) {
console.log(error);
// errorString passed from the API (with a 403 error)
const errorString = 'user as customer is not setup yet';
// if it's beamer's error indicating the account is not linked yet
if (error.message && error.message.indexOf(errorString) > -1) {
// send them to save a card
that.setState({
currentFlowStage: 'confirmingCard',
});
// get a payment method secret for frontend
Lbryio.call(
'customer',
'setup',
{
environment: stripeEnvironment,
},
'post'
).then((customerSetupResponse) => {
console.log(customerSetupResponse);
clientSecret = customerSetupResponse.client_secret;
// instantiate stripe elements
setupStripe();
});
} else if (error === 'internal_apis_down') {
var displayString = 'There was an error from the server, please let support know';
doToast({ message: displayString, isError: true });
} else {
console.log('Unseen before error');
}
});
}
}, 250);
function setupStripe() {
@ -261,8 +250,6 @@ class SettingsStripeCard extends React.Component<Props, State> {
})
.then(function (result) {
if (result.error) {
console.log(result);
changeLoadingState(false);
var displayError = document.getElementById('card-errors');
displayError.textContent = result.error.message;
@ -346,8 +333,6 @@ class SettingsStripeCard extends React.Component<Props, State> {
});
});
console.log(result);
changeLoadingState(false);
});
};
@ -366,13 +351,13 @@ class SettingsStripeCard extends React.Component<Props, State> {
const { scriptFailedToLoad, openModal } = this.props;
const { currentFlowStage, customerTransactions, pageTitle, userCardDetails, paymentMethodId } = this.state;
const { currentFlowStage, pageTitle, userCardDetails, paymentMethodId } = this.state;
return (
<Page backout={{ title: pageTitle, backLabel: __('Done') }} noFooter noSideNavigation>
<div>
{scriptFailedToLoad && (
<div className="error__text">There was an error connecting to Stripe. Please try again later.</div>
<div className="error__text">{__('There was an error connecting to Stripe. Please try again later.')}</div>
)}
</div>
@ -430,72 +415,18 @@ class SettingsStripeCard extends React.Component<Props, State> {
/>
</>
}
actions={
<Button
button="primary"
label={__('View Transactions')}
icon={ICONS.SETTINGS}
navigate={`/$/${PAGES.WALLET}?tab=fiat-account-history`}
/>
}
/>
<br />
{/* if a user has no transactions yet */}
{(!customerTransactions || customerTransactions.length === 0) && (
<Card
title={__('Tip History')}
subtitle={__('You have not sent any tips yet. When you do they will appear here. ')}
/>
)}
</div>
)}
{/* customer already has transactions */}
{customerTransactions && customerTransactions.length > 0 && (
<Card
title={__('Tip History')}
body={
<>
<div className="table__wrapper">
<table className="table table--transactions">
<thead>
<tr>
<th className="date-header">{__('Date')}</th>
<th>{<>{__('Receiving Channel Name')}</>}</th>
<th>{__('Tip Location')}</th>
<th>{__('Amount (USD)')} </th>
<th>{__('Anonymous')}</th>
</tr>
</thead>
<tbody>
{customerTransactions &&
customerTransactions.reverse().map((transaction) => (
<tr key={transaction.name + transaction.created_at}>
<td>{moment(transaction.created_at).format('LLL')}</td>
<td>
<Button
className="stripe__card-link-text"
navigate={'/' + transaction.channel_name + ':' + transaction.channel_claim_id}
label={transaction.channel_name}
button="link"
/>
</td>
<td>
<Button
className="stripe__card-link-text"
navigate={'/' + transaction.channel_name + ':' + transaction.source_claim_id}
label={
transaction.channel_claim_id === transaction.source_claim_id
? 'Channel Page'
: 'File Page'
}
button="link"
/>
</td>
<td>${transaction.tipped_amount / 100}</td>
<td>{transaction.private_tip ? 'Yes' : 'No'}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
}
/>
)}
</Page>
);
}

View file

@ -1,5 +1,5 @@
// @flow
import { DOMAIN } from 'config';
import { DOMAIN, ENABLE_NO_SOURCE_CLAIMS } from 'config';
import * as PAGES from 'constants/pages';
import React, { useEffect } from 'react';
import { lazyImport } from 'util/lazyImport';
@ -199,7 +199,7 @@ function ShowPage(props: Props) {
/>
</Page>
);
} else if (isLivestream) {
} else if (isLivestream && ENABLE_NO_SOURCE_CLAIMS) {
innerContent = <LivestreamPage uri={uri} />;
} else {
innerContent = <FilePage uri={uri} location={location} />;

View file

@ -1,11 +1,29 @@
// @flow
import React from 'react';
import { withRouter } from 'react-router';
import { useHistory } from 'react-router';
import WalletBalance from 'component/walletBalance';
import WalletFiatBalance from 'component/walletFiatBalance';
import WalletFiatPaymentBalance from 'component/walletFiatPaymentBalance';
import WalletFiatAccountHistory from 'component/walletFiatAccountHistory';
import WalletFiatPaymentHistory from 'component/walletFiatPaymentHistory';
import TxoList from 'component/txoList';
import Page from 'component/page';
import * as PAGES from 'constants/pages';
import Spinner from 'component/spinner';
import YrblWalletEmpty from 'component/yrblWalletEmpty';
import { Lbryio } from 'lbryinc';
import { Tabs, TabList, Tab, TabPanels, TabPanel } from 'component/common/tabs';
import { getStripeEnvironment } from 'util/stripe';
const TAB_QUERY = 'tab';
const TABS = {
LBRY_CREDITS_TAB: 'credits',
ACCOUNT_HISTORY: 'fiat-account-history',
PAYMENT_HISTORY: 'fiat-payment-history',
};
let stripeEnvironment = getStripeEnvironment();
type Props = {
history: { action: string, push: (string) => void, replace: (string) => void },
@ -14,32 +32,209 @@ type Props = {
};
const WalletPage = (props: Props) => {
const { location, totalBalance } = props;
const { search } = location;
const {
location: { search },
push,
} = useHistory();
// @if TARGET='web'
const urlParams = new URLSearchParams(search);
const currentView = urlParams.get(TAB_QUERY) || TABS.LBRY_CREDITS_TAB;
let tabIndex;
switch (currentView) {
case TABS.LBRY_CREDITS_TAB:
tabIndex = 0;
break;
case TABS.PAYMENT_HISTORY:
tabIndex = 1;
break;
case TABS.ACCOUNT_HISTORY:
tabIndex = 2;
break;
default:
tabIndex = 0;
break;
}
function onTabChange(newTabIndex) {
let url = `/$/${PAGES.WALLET}?`;
if (newTabIndex === 0) {
url += `${TAB_QUERY}=${TABS.LBRY_CREDITS_TAB}`;
} else if (newTabIndex === 1) {
url += `${TAB_QUERY}=${TABS.PAYMENT_HISTORY}`;
} else if (newTabIndex === 2) {
url += `${TAB_QUERY}=${TABS.ACCOUNT_HISTORY}`;
} else {
url += `${TAB_QUERY}=${TABS.LBRY_CREDITS_TAB}`;
}
push(url);
}
const [accountStatusResponse, setAccountStatusResponse] = React.useState();
const [accountTransactionResponse, setAccountTransactionResponse] = React.useState([]);
const [customerTransactions, setCustomerTransactions] = React.useState([]);
function getPaymentHistory() {
return Lbryio.call(
'customer',
'list',
{
environment: stripeEnvironment,
},
'post'
);
}
function getAccountStatus() {
return Lbryio.call(
'account',
'status',
{
environment: stripeEnvironment,
},
'post'
);
}
function getAccountTransactionsa() {
return Lbryio.call(
'account',
'list',
{
environment: stripeEnvironment,
},
'post'
);
}
// calculate account transactions section
React.useEffect(() => {
(async function () {
try {
if (!stripeEnvironment) {
return;
}
const response = await getAccountStatus();
setAccountStatusResponse(response);
// TODO: some weird naming clash hence getAccountTransactionsa
const getAccountTransactions = await getAccountTransactionsa();
setAccountTransactionResponse(getAccountTransactions);
} catch (err) {
console.log(err);
}
})();
}, [stripeEnvironment]);
// populate customer payment data
React.useEffect(() => {
(async function () {
try {
// get card payments customer has made
if (!stripeEnvironment) {
return;
}
let customerTransactionResponse = await getPaymentHistory();
if (customerTransactionResponse && customerTransactionResponse.length) {
customerTransactionResponse.reverse();
}
setCustomerTransactions(customerTransactionResponse);
} catch (err) {
console.log(err);
}
})();
}, [stripeEnvironment]);
// @endif
const { totalBalance } = props;
const showIntro = totalBalance === 0;
const loading = totalBalance === undefined;
return (
<Page>
{loading && (
<div className="main--empty">
<Spinner delayed />
</div>
<>
{stripeEnvironment && (
<Page>
<Tabs onChange={onTabChange} index={tabIndex}>
<TabList className="tabs__list--collection-edit-page">
<Tab>{__('LBRY Credits')}</Tab>
<Tab>{__('Account History')}</Tab>
<Tab>{__('Payment History')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<div className="section card-stack">
<div className="lbc-transactions">
{/* if the transactions are loading */}
{loading && (
<div className="main--empty">
<Spinner delayed />
</div>
)}
{/* when the transactions are finished loading */}
{!loading && (
<>
{showIntro ? (
<YrblWalletEmpty includeWalletLink />
) : (
<div className="card-stack">
<WalletBalance />
<TxoList search={search} />
</div>
)}
</>
)}
</div>
</div>
</TabPanel>
<TabPanel>
<div className="section card-stack">
<WalletFiatBalance accountDetails={accountStatusResponse} />
<WalletFiatAccountHistory transactions={accountTransactionResponse} />
</div>
</TabPanel>
<TabPanel>
<div className="section card-stack">
<WalletFiatPaymentBalance
transactions={customerTransactions}
accountDetails={accountStatusResponse}
/>
<WalletFiatPaymentHistory transactions={customerTransactions} />
</div>
</TabPanel>
</TabPanels>
</Tabs>
</Page>
)}
{!loading && (
<>
{showIntro ? (
<YrblWalletEmpty includeWalletLink />
) : (
<div className="card-stack">
<WalletBalance />
<TxoList search={search} />
{!stripeEnvironment && (
<Page>
{loading && (
<div className="main--empty">
<Spinner delayed />
</div>
)}
</>
{!loading && (
<>
{showIntro ? (
<YrblWalletEmpty includeWalletLink />
) : (
<div className="card-stack">
<WalletBalance />
<TxoList search={search} />
</div>
)}
</>
)}
</Page>
)}
</Page>
</>
);
};
export default withRouter(WalletPage);
export default WalletPage;

View file

@ -28,6 +28,7 @@ import { selectPrefsReady } from 'redux/selectors/sync';
import { doAlertWaitingForSync } from 'redux/actions/app';
const isDev = process.env.NODE_ENV !== 'production';
const FETCH_API_FAILED_TO_FETCH = 'Failed to fetch';
const COMMENTRON_MSG_REMAP = {
// <-- Commentron msg --> : <-- App msg -->
@ -103,7 +104,7 @@ export function doCommentList(
totalFilteredItems: total_filtered_items,
totalPages: total_pages,
claimId: claimId,
authorClaimId: authorChannelClaim ? authorChannelClaim.claim_id : undefined,
commenterClaimId: authorChannelClaim ? authorChannelClaim.claim_id : undefined,
uri: uri,
},
});
@ -111,20 +112,32 @@ export function doCommentList(
return result;
})
.catch((error) => {
if (error.message === 'comments are disabled by the creator') {
dispatch({
type: ACTIONS.COMMENT_LIST_COMPLETED,
data: {
authorClaimId: authorChannelClaim ? authorChannelClaim.claim_id : undefined,
disabled: true,
},
});
} else {
devToast(dispatch, `doCommentList: ${error.message}`);
dispatch({
type: ACTIONS.COMMENT_LIST_FAILED,
data: error,
});
switch (error.message) {
case 'comments are disabled by the creator':
dispatch({
type: ACTIONS.COMMENT_LIST_COMPLETED,
data: {
authorClaimId: authorChannelClaim ? authorChannelClaim.claim_id : undefined,
disabled: true,
},
});
break;
case FETCH_API_FAILED_TO_FETCH:
dispatch(
doToast({
isError: true,
message: Comments.isCustomServer
? __('Failed to fetch comments. Verify custom server settings.')
: __('Failed to fetch comments.'),
})
);
dispatch({ type: ACTIONS.COMMENT_LIST_FAILED, data: error });
break;
default:
dispatch(doToast({ isError: true, message: `${error.message}` }));
dispatch({ type: ACTIONS.COMMENT_LIST_FAILED, data: error });
}
});
};
@ -261,7 +274,6 @@ export function doCommentReactList(commentIds: Array<string>) {
});
})
.catch((error) => {
devToast(dispatch, `doCommentReactList: ${error.message}`);
dispatch({
type: ACTIONS.COMMENT_REACTION_LIST_FAILED,
data: error,
@ -1345,7 +1357,7 @@ export function doFetchCommentModAmIList(channelClaim: ChannelClaim) {
};
}
export const doFetchCreatorSettings = (channelClaimIds: Array<string> = []) => {
export const doFetchCreatorSettings = (channelId: string) => {
return async (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const myChannels = selectMyChannelClaims(state);
@ -1354,84 +1366,49 @@ export const doFetchCreatorSettings = (channelClaimIds: Array<string> = []) => {
type: ACTIONS.COMMENT_FETCH_SETTINGS_STARTED,
});
let channelSignatures = [];
let signedName;
if (myChannels) {
for (const channelClaim of myChannels) {
if (channelClaimIds.length !== 0 && !channelClaimIds.includes(channelClaim.claim_id)) {
continue;
}
try {
const channelSignature = await Lbry.channel_sign({
channel_id: channelClaim.claim_id,
hexdata: toHex(channelClaim.name),
});
channelSignatures.push({ ...channelSignature, claim_id: channelClaim.claim_id, name: channelClaim.name });
} catch (e) {}
const index = myChannels.findIndex((myChannel) => myChannel.claim_id === channelId);
if (index > -1) {
signedName = await channelSignName(channelId, myChannels[index].name);
}
}
return Promise.all(
channelSignatures.map((signatureData) =>
Comments.setting_list({
channel_name: signatureData.name,
channel_id: signatureData.claim_id,
signature: signatureData.signature,
signing_ts: signatureData.signing_ts,
})
)
)
.then((settings) => {
const settingsByChannelId = {};
for (let i = 0; i < channelSignatures.length; ++i) {
const channelId = channelSignatures[i].claim_id;
settingsByChannelId[channelId] = settings[i];
if (settings[i].words) {
settingsByChannelId[channelId].words = settings[i].words.split(',');
}
delete settingsByChannelId[channelId].channel_name;
delete settingsByChannelId[channelId].channel_id;
delete settingsByChannelId[channelId].signature;
delete settingsByChannelId[channelId].signing_ts;
}
const cmd = signedName ? Comments.setting_list : Comments.setting_get;
return cmd({
channel_id: channelId,
channel_name: (signedName && signedName.name) || undefined,
signature: (signedName && signedName.signature) || undefined,
signing_ts: (signedName && signedName.signing_ts) || undefined,
})
.then((response: SettingsResponse) => {
dispatch({
type: ACTIONS.COMMENT_FETCH_SETTINGS_COMPLETED,
data: settingsByChannelId,
data: {
channelId: channelId,
settings: response,
partialUpdate: !signedName,
},
});
})
.catch((err) => {
// TODO: Use error codes when available.
// TODO: The "validation is disallowed" thing ideally should just be a
// success case that returns a null setting, instead of an error.
// As we are using 'Promise.all', if one channel fails, everyone
// fails. This forces us to remove the batch functionality of this
// function. However, since this "validation is disallowed" thing
// is potentially a temporary one to handle spammers, I retained
// the batch functionality for now.
if (err.message === 'validation is disallowed for non controlling channels') {
const settingsByChannelId = {};
for (let i = 0; i < channelSignatures.length; ++i) {
const channelId = channelSignatures[i].claim_id;
// 'undefined' means "fetching or have not fetched";
// 'null' means "feature not available for this channel";
settingsByChannelId[channelId] = null;
}
dispatch({
type: ACTIONS.COMMENT_FETCH_SETTINGS_COMPLETED,
data: settingsByChannelId,
data: {
channelId: channelId,
settings: null,
partialUpdate: !signedName,
},
});
} else {
devToast(dispatch, `Creator: ${err}`);
dispatch({
type: ACTIONS.COMMENT_FETCH_SETTINGS_FAILED,
});
return;
}
dispatch({
type: ACTIONS.COMMENT_FETCH_SETTINGS_FAILED,
});
});
};
};
@ -1442,22 +1419,13 @@ export const doFetchCreatorSettings = (channelClaimIds: Array<string> = []) => {
*
* @param channelClaim
* @param settings
* @returns {function(Dispatch, GetState): Promise<R>|Promise<unknown>|*}
* @returns {function(Dispatch, GetState): any}
*/
export const doUpdateCreatorSettings = (channelClaim: ChannelClaim, settings: PerChannelSettings) => {
return async (dispatch: Dispatch, getState: GetState) => {
let channelSignature: ?{
signature: string,
signing_ts: string,
};
try {
channelSignature = await Lbry.channel_sign({
channel_id: channelClaim.claim_id,
hexdata: toHex(channelClaim.name),
});
} catch (e) {}
const channelSignature = await channelSignName(channelClaim.claim_id, channelClaim.name);
if (!channelSignature) {
devToast(dispatch, 'doUpdateCreatorSettings: failed to sign channel name');
return;
}
@ -1468,12 +1436,7 @@ export const doUpdateCreatorSettings = (channelClaim: ChannelClaim, settings: Pe
signing_ts: channelSignature.signing_ts,
...settings,
}).catch((err) => {
dispatch(
doToast({
message: err.message,
isError: true,
})
);
dispatch(doToast({ message: err.message, isError: true }));
});
};
};

View file

@ -6,13 +6,13 @@ import { doFetchInviteStatus } from 'redux/actions/user';
import rewards from 'rewards';
export function doRewardList() {
return dispatch => {
return (dispatch) => {
dispatch({
type: ACTIONS.FETCH_REWARDS_STARTED,
});
Lbryio.call('reward', 'list', { multiple_rewards_per_type: true })
.then(userRewards => {
.then((userRewards) => {
dispatch({
type: ACTIONS.FETCH_REWARDS_COMPLETED,
data: { userRewards },
@ -35,7 +35,7 @@ export function doClaimRewardType(rewardType, options = {}) {
const reward =
rewardType === rewards.TYPE_REWARD_CODE || rewardType === rewards.TYPE_NEW_ANDROID
? { reward_type: rewards.TYPE_REWARD_CODE }
: unclaimedRewards.find(ur => ur.reward_type === rewardType);
: unclaimedRewards.find((ur) => ur.reward_type === rewardType);
// Try to claim the email reward right away, even if we haven't called reward_list yet
if (
@ -74,7 +74,7 @@ export function doClaimRewardType(rewardType, options = {}) {
data: { reward },
});
const success = successReward => {
const success = (successReward) => {
// Temporary timeout to ensure the sdk has the correct balance after claiming a reward
setTimeout(() => {
dispatch(doUpdateBalance()).then(() => {
@ -99,7 +99,7 @@ export function doClaimRewardType(rewardType, options = {}) {
}, 2000);
};
const failure = error => {
const failure = (error) => {
dispatch({
type: ACTIONS.CLAIM_REWARD_FAILURE,
data: {
@ -121,6 +121,13 @@ export function doClaimRewardType(rewardType, options = {}) {
};
}
export function doClaimInitialRewards() {
return (dispatch) => {
dispatch(doClaimRewardType(rewards.TYPE_NEW_USER));
dispatch(doClaimRewardType(rewards.TYPE_CONFIRM_EMAIL));
};
}
export function doClaimEligiblePurchaseRewards() {
return (dispatch, getState) => {
const state = getState();
@ -131,10 +138,10 @@ export function doClaimEligiblePurchaseRewards() {
return;
}
if (unclaimedRewards.find(ur => ur.reward_type === rewards.TYPE_FIRST_STREAM)) {
if (unclaimedRewards.find((ur) => ur.reward_type === rewards.TYPE_FIRST_STREAM)) {
dispatch(doClaimRewardType(rewards.TYPE_FIRST_STREAM));
} else {
[rewards.TYPE_MANY_DOWNLOADS, rewards.TYPE_DAILY_VIEW].forEach(type => {
[rewards.TYPE_MANY_DOWNLOADS, rewards.TYPE_DAILY_VIEW].forEach((type) => {
dispatch(doClaimRewardType(type, { failSilently: true }));
});
}
@ -142,7 +149,7 @@ export function doClaimEligiblePurchaseRewards() {
}
export function doClaimRewardClearError(reward) {
return dispatch => {
return (dispatch) => {
dispatch({
type: ACTIONS.CLAIM_REWARD_CLEAR_ERROR,
data: { reward },
@ -151,8 +158,8 @@ export function doClaimRewardClearError(reward) {
}
export function doFetchRewardedContent() {
return dispatch => {
const success = nameToClaimId => {
return (dispatch) => {
const success = (nameToClaimId) => {
dispatch({
type: ACTIONS.FETCH_REWARD_CONTENT_COMPLETED,
data: {

View file

@ -2,7 +2,7 @@
import * as ACTIONS from 'constants/action_types';
import { SEARCH_OPTIONS } from 'constants/search';
import { buildURI, doResolveUris, batchActions, isURIValid, makeSelectClaimForUri } from 'lbry-redux';
import { makeSelectSearchUris, selectSearchValue } from 'redux/selectors/search';
import { makeSelectSearchUrisForQuery, selectSearchValue } from 'redux/selectors/search';
import handleFetchResponse from 'util/handle-fetch';
import { getSearchQueryString } from 'util/query-params';
import { SIMPLE_SITE, SEARCH_SERVER_API } from 'config';
@ -48,7 +48,7 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
const from = searchOptions.from;
// If we have already searched for something, we don't need to do anything
const urisForQuery = makeSelectSearchUris(queryWithOptions)(state);
const urisForQuery = makeSelectSearchUrisForQuery(queryWithOptions)(state);
if (urisForQuery && !!urisForQuery.length) {
if (!size || !from || from + size < urisForQuery.length) {
return;
@ -61,13 +61,14 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
lighthouse
.search(queryWithOptions)
.then((data: Array<{ name: string, claimId: string }>) => {
.then((data: { body: Array<{ name: string, claimId: string }>, poweredBy: string }) => {
const { body: result, poweredBy } = data;
const uris = [];
const actions = [];
data.forEach((result) => {
if (result) {
const { name, claimId } = result;
result.forEach((item) => {
if (item) {
const { name, claimId } = item;
const urlObj: LbryUrlObj = {};
if (name.startsWith('@')) {
@ -94,6 +95,7 @@ export const doSearch = (rawQuery: string, searchOptions: SearchOptions) => (
from: from,
size: size,
uris,
recsys: poweredBy,
},
});
dispatch(batchActions(...actions));

View file

@ -3,9 +3,10 @@ import { Lbryio } from 'lbryinc';
import { SETTINGS, Lbry, doWalletEncrypt, doWalletDecrypt } from 'lbry-redux';
import { selectGetSyncIsPending, selectSetSyncIsPending, selectSyncIsLocked } from 'redux/selectors/sync';
import { makeSelectClientSetting } from 'redux/selectors/settings';
import { getSavedPassword } from 'util/saved-passwords';
import { getSavedPassword, getAuthToken } from 'util/saved-passwords';
import { doAnalyticsTagSync, doHandleSyncComplete } from 'redux/actions/app';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
import { X_LBRY_AUTH_TOKEN } from 'constants/token';
let syncTimer = null;
const SYNC_INTERVAL = 1000 * 60 * 5; // 5 minutes
@ -148,6 +149,17 @@ export function doGetSync(passedPassword, callback) {
}
}
// @if TARGET='web'
const xAuth =
Lbry.getApiRequestHeaders() && Object.keys(Lbry.getApiRequestHeaders()).includes(X_LBRY_AUTH_TOKEN)
? Lbry.getApiRequestHeaders()[X_LBRY_AUTH_TOKEN]
: '';
if (xAuth && xAuth !== getAuthToken()) {
window.location.reload();
return;
}
// @endif
return (dispatch) => {
dispatch({
type: ACTIONS.GET_SYNC_STARTED,

View file

@ -110,6 +110,17 @@ export const doCommentSocketConnect = (uri, claimId) => (dispatch) => {
data: { connected, claimId },
});
}
if (response.type === 'pinned') {
const pinnedComment = response.data.comment;
dispatch({
type: ACTIONS.COMMENT_PIN_COMPLETED,
data: {
pinnedComment: pinnedComment,
claimId,
unpin: !pinnedComment.is_pinned,
},
});
}
});
};

View file

@ -19,6 +19,7 @@ const defaultState: CommentsState = {
commentsByUri: {}, // URI -> claimId
linkedCommentAncestors: {}, // {"linkedCommentId": ["parentId", "grandParentId", ...]}
superChatsByUri: {},
pinnedCommentsById: {}, // ClaimId -> array of pinned comment IDs
isLoading: false,
isLoadingByParentId: {},
isCommenting: false,
@ -40,7 +41,6 @@ const defaultState: CommentsState = {
blockingByUri: {},
unBlockingByUri: {},
togglingForDelegatorMap: {},
commentsDisabledChannelIds: [],
settingsByChannelId: {}, // ChannelId -> PerChannelSettings
fetchingSettings: false,
fetchingBlockedWords: false,
@ -250,32 +250,8 @@ export default handleActions(
claimId,
uri,
disabled,
authorClaimId,
commenterClaimId,
} = action.data;
const commentsDisabledChannelIds = [...state.commentsDisabledChannelIds];
if (disabled) {
if (!commentsDisabledChannelIds.includes(authorClaimId)) {
commentsDisabledChannelIds.push(authorClaimId);
}
const isLoadingByParentId = Object.assign({}, state.isLoadingByParentId);
if (parentId) {
isLoadingByParentId[parentId] = false;
}
return {
...state,
commentsDisabledChannelIds,
isLoading: false,
isLoadingByParentId,
};
} else {
const index = commentsDisabledChannelIds.indexOf(authorClaimId);
if (index > -1) {
commentsDisabledChannelIds.splice(index, 1);
}
}
const commentById = Object.assign({}, state.commentById);
const byId = Object.assign({}, state.byId);
@ -285,49 +261,65 @@ export default handleActions(
const commentsByUri = Object.assign({}, state.commentsByUri);
const repliesByParentId = Object.assign({}, state.repliesByParentId);
const totalCommentsById = Object.assign({}, state.totalCommentsById);
const pinnedCommentsById = Object.assign({}, state.pinnedCommentsById);
const totalRepliesByParentId = Object.assign({}, state.totalRepliesByParentId);
const isLoadingByParentId = Object.assign({}, state.isLoadingByParentId);
const settingsByChannelId = Object.assign({}, state.settingsByChannelId);
if (!parentId) {
totalCommentsById[claimId] = totalItems;
topLevelTotalCommentsById[claimId] = totalFilteredItems;
topLevelTotalPagesById[claimId] = totalPages;
} else {
totalRepliesByParentId[parentId] = totalFilteredItems;
settingsByChannelId[commenterClaimId] = {
...(settingsByChannelId[commenterClaimId] || {}),
comments_enabled: !disabled,
};
if (parentId) {
isLoadingByParentId[parentId] = false;
}
const commonUpdateAction = (comment, commentById, commentIds, index) => {
// map the comment_ids to the new comments
commentById[comment.comment_id] = comment;
commentIds[index] = comment.comment_id;
};
if (comments) {
// we use an Array to preserve order of listing
// in reality this doesn't matter and we can just
// sort comments by their timestamp
const commentIds = Array(comments.length);
// --- Top-level comments ---
if (!parentId) {
for (let i = 0; i < comments.length; ++i) {
const comment = comments[i];
commonUpdateAction(comment, commentById, commentIds, i);
pushToArrayInObject(topLevelCommentsById, claimId, comment.comment_id);
}
}
// --- Replies ---
else {
for (let i = 0; i < comments.length; ++i) {
const comment = comments[i];
commonUpdateAction(comment, commentById, commentIds, i);
pushToArrayInObject(repliesByParentId, parentId, comment.comment_id);
}
if (!disabled) {
if (parentId) {
totalRepliesByParentId[parentId] = totalFilteredItems;
} else {
totalCommentsById[claimId] = totalItems;
topLevelTotalCommentsById[claimId] = totalFilteredItems;
topLevelTotalPagesById[claimId] = totalPages;
}
byId[claimId] ? byId[claimId].push(...commentIds) : (byId[claimId] = commentIds);
commentsByUri[uri] = claimId;
const commonUpdateAction = (comment, commentById, commentIds, index) => {
// map the comment_ids to the new comments
commentById[comment.comment_id] = comment;
commentIds[index] = comment.comment_id;
};
if (comments) {
// we use an Array to preserve order of listing
// in reality this doesn't matter and we can just
// sort comments by their timestamp
const commentIds = Array(comments.length);
// --- Top-level comments ---
if (!parentId) {
for (let i = 0; i < comments.length; ++i) {
const comment = comments[i];
commonUpdateAction(comment, commentById, commentIds, i);
if (comment.is_pinned) {
pushToArrayInObject(pinnedCommentsById, claimId, comment.comment_id);
} else {
pushToArrayInObject(topLevelCommentsById, claimId, comment.comment_id);
}
}
}
// --- Replies ---
else {
for (let i = 0; i < comments.length; ++i) {
const comment = comments[i];
commonUpdateAction(comment, commentById, commentIds, i);
pushToArrayInObject(repliesByParentId, parentId, comment.comment_id);
}
}
byId[claimId] ? byId[claimId].push(...commentIds) : (byId[claimId] = commentIds);
commentsByUri[uri] = claimId;
}
}
return {
@ -337,13 +329,14 @@ export default handleActions(
topLevelTotalPagesById,
repliesByParentId,
totalCommentsById,
pinnedCommentsById,
totalRepliesByParentId,
byId,
commentById,
commentsByUri,
commentsDisabledChannelIds,
isLoading: false,
isLoadingByParentId,
settingsByChannelId,
};
},
@ -621,21 +614,51 @@ export default handleActions(
const { pinnedComment, claimId, unpin } = action.data;
const commentById = Object.assign({}, state.commentById);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById);
const pinnedCommentsById = Object.assign({}, state.pinnedCommentsById);
if (pinnedComment && topLevelCommentsById[claimId]) {
const index = topLevelCommentsById[claimId].indexOf(pinnedComment.comment_id);
if (index > -1) {
topLevelCommentsById[claimId].splice(index, 1);
if (unpin) {
// Without the sort score, I have no idea where to put it. Just
// dump it at the bottom. Users can refresh if they want it back to
// the correct sorted position.
topLevelCommentsById[claimId].push(pinnedComment.comment_id);
} else {
topLevelCommentsById[claimId].unshift(pinnedComment.comment_id);
if (pinnedComment) {
if (topLevelCommentsById[claimId]) {
const index = topLevelCommentsById[claimId].indexOf(pinnedComment.comment_id);
if (index > -1) {
topLevelCommentsById[claimId].splice(index, 1);
}
} else {
topLevelCommentsById[claimId] = [];
}
if (pinnedCommentsById[claimId]) {
const index = pinnedCommentsById[claimId].indexOf(pinnedComment.comment_id);
if (index > -1) {
pinnedCommentsById[claimId].splice(index, 1);
}
} else {
pinnedCommentsById[claimId] = [];
}
if (unpin) {
// Without the sort score, I have no idea where to put it. Just
// dump it at the top. Users can refresh if they want it back to
// the correct sorted position.
topLevelCommentsById[claimId].unshift(pinnedComment.comment_id);
} else {
pinnedCommentsById[claimId].unshift(pinnedComment.comment_id);
}
if (commentById[pinnedComment.comment_id]) {
// Commentron's `comment.Pin` response places the creator's credentials
// in the 'channel_*' fields, which doesn't make sense. Maybe it is to
// show who signed/pinned it, but even if so, it shouldn't overload
// these variables which are already used by existing comment data structure.
// Ensure we don't override the existing/correct values, but fallback
// to whatever was given.
const { channel_id, channel_name, channel_url } = commentById[pinnedComment.comment_id];
commentById[pinnedComment.comment_id] = {
...pinnedComment,
channel_id: channel_id || pinnedComment.channel_id,
channel_name: channel_name || pinnedComment.channel_name,
channel_url: channel_url || pinnedComment.channel_url,
};
} else {
commentById[pinnedComment.comment_id] = pinnedComment;
}
}
@ -644,6 +667,7 @@ export default handleActions(
...state,
commentById,
topLevelCommentsById,
pinnedCommentsById,
};
},
@ -986,14 +1010,26 @@ export default handleActions(
fetchingSettings: false,
}),
[ACTIONS.COMMENT_FETCH_SETTINGS_COMPLETED]: (state: CommentsState, action: any) => {
// TODO: This is incorrect, as it could make 'settingsByChannelId' store
// only 1 channel with other channel's data purged. It works for now
// because the GUI only shows 1 channel's setting at a time, and *always*
// re-fetches to get latest data before displaying. Either rename this to
// 'activeChannelCreatorSettings', or append the new data properly.
const { channelId, settings, partialUpdate } = action.data;
const settingsByChannelId = Object.assign({}, state.settingsByChannelId);
if (partialUpdate) {
settingsByChannelId[channelId] = {
// The existing may contain additional Creator Settings (e.g. 'words')
...(settingsByChannelId[channelId] || {}),
// Spread new settings.
...settings,
};
} else {
settingsByChannelId[channelId] = settings;
if (settings.words) {
settingsByChannelId[channelId].words = settings.words.split(',');
}
}
return {
...state,
settingsByChannelId: action.data,
settingsByChannelId,
fetchingSettings: false,
};
},

View file

@ -17,7 +17,7 @@ const defaultState: SearchState = {
[SEARCH_OPTIONS.MEDIA_IMAGE]: defaultSearchTypes.includes(SEARCH_OPTIONS.MEDIA_IMAGE),
[SEARCH_OPTIONS.MEDIA_APPLICATION]: defaultSearchTypes.includes(SEARCH_OPTIONS.MEDIA_APPLICATION),
},
urisByQuery: {},
resultsByQuery: {},
hasReachedMaxResultsLength: {},
searching: false,
};
@ -29,21 +29,23 @@ export default handleActions(
searching: true,
}),
[ACTIONS.SEARCH_SUCCESS]: (state: SearchState, action: SearchSuccess): SearchState => {
const { query, uris, from, size } = action.data;
const { query, uris, from, size, recsys } = action.data;
const normalizedQuery = createNormalizedSearchKey(query);
const urisForQuery = state.resultsByQuery[normalizedQuery] && state.resultsByQuery[normalizedQuery]['uris'];
let newUris = uris;
if (from !== 0 && state.urisByQuery[normalizedQuery]) {
newUris = Array.from(new Set(state.urisByQuery[normalizedQuery].concat(uris)));
if (from !== 0 && urisForQuery) {
newUris = Array.from(new Set(urisForQuery.concat(uris)));
}
// The returned number of urls is less than the page size, so we're on the last page
const noMoreResults = size && uris.length < size;
const results = { uris: newUris, recsys };
return {
...state,
searching: false,
urisByQuery: Object.assign({}, state.urisByQuery, { [normalizedQuery]: newUris }),
resultsByQuery: Object.assign({}, state.resultsByQuery, { [normalizedQuery]: results }),
hasReachedMaxResultsLength: Object.assign({}, state.hasReachedMaxResultsLength, {
[normalizedQuery]: noMoreResults,
}),

View file

@ -4,6 +4,7 @@ import { ACTIONS as LBRY_REDUX_ACTIONS, SETTINGS, SHARED_PREFERENCES } from 'lbr
import { getSubsetFromKeysArray } from 'util/sync-settings';
import { getDefaultLanguage } from 'util/default-languages';
import { UNSYNCED_SETTINGS, SIMPLE_SITE } from 'config';
import Comments from 'comments';
const { CLIENT_SYNC_KEYS } = SHARED_PREFERENCES;
const settingsToIgnore = (UNSYNCED_SETTINGS && UNSYNCED_SETTINGS.trim().split(' ')) || [];
@ -51,6 +52,8 @@ const defaultState = {
[SETTINGS.VIDEO_THEATER_MODE]: false,
[SETTINGS.VIDEO_PLAYBACK_RATE]: 1,
[SETTINGS.DESKTOP_WINDOW_ZOOM]: 1,
[SETTINGS.CUSTOM_COMMENTS_SERVER_ENABLED]: false,
[SETTINGS.CUSTOM_COMMENTS_SERVER_URL]: '',
[SETTINGS.DARK_MODE_TIMES]: {
from: { hour: '21', min: '00', formattedTime: '21:00' },
@ -169,6 +172,13 @@ reducers[LBRY_REDUX_ACTIONS.USER_STATE_POPULATE] = (state, action) => {
const selectedSettings = sharedPreferences ? getSubsetFromKeysArray(sharedPreferences, clientSyncKeys) : {};
const mergedClientSettings = { ...currentClientSettings, ...selectedSettings };
const newSharedPreferences = sharedPreferences || {};
Comments.setServerUrl(
mergedClientSettings[SETTINGS.CUSTOM_COMMENTS_SERVER_ENABLED]
? mergedClientSettings[SETTINGS.CUSTOM_COMMENTS_SERVER_URL]
: undefined
);
return Object.assign({}, state, {
sharedPreferences: newSharedPreferences,
clientSettings: mergedClientSettings,

View file

@ -3,7 +3,7 @@ import { createSelector } from 'reselect';
import { selectMutedChannels } from 'redux/selectors/blocked';
import { selectShowMatureContent } from 'redux/selectors/settings';
import { selectBlacklistedOutpointMap, selectFilteredOutpointMap } from 'lbryinc';
import { selectClaimsById, isClaimNsfw, selectMyActiveClaims, makeSelectClaimForUri } from 'lbry-redux';
import { selectClaimsById, isClaimNsfw, selectMyActiveClaims } from 'lbry-redux';
const selectState = (state) => state.comments || {};
@ -12,12 +12,29 @@ export const selectIsFetchingComments = createSelector(selectState, (state) => s
export const selectIsFetchingCommentsByParentId = createSelector(selectState, (state) => state.isLoadingByParentId);
export const selectIsPostingComment = createSelector(selectState, (state) => state.isCommenting);
export const selectIsFetchingReacts = createSelector(selectState, (state) => state.isFetchingReacts);
export const selectCommentsDisabledChannelIds = createSelector(
selectState,
(state) => state.commentsDisabledChannelIds
);
export const selectOthersReactsById = createSelector(selectState, (state) => state.othersReactsByCommentId);
export const selectPinnedCommentsById = createSelector(selectState, (state) => state.pinnedCommentsById);
export const makeSelectPinnedCommentsForUri = (uri: string) =>
createSelector(
selectCommentsByUri,
selectCommentsById,
selectPinnedCommentsById,
(byUri, byId, pinnedCommentsById) => {
const claimId = byUri[uri];
const pinnedCommentIds = pinnedCommentsById && pinnedCommentsById[claimId];
const pinnedComments = [];
if (pinnedCommentIds) {
pinnedCommentIds.forEach((commentId) => {
pinnedComments.push(byId[commentId]);
});
}
return pinnedComments;
}
);
export const selectModerationBlockList = createSelector(selectState, (state) =>
state.moderationBlockList ? state.moderationBlockList.reverse() : []
);
@ -425,15 +442,3 @@ export const makeSelectSuperChatTotalAmountForUri = (uri: string) =>
return superChatData.totalAmount;
});
export const makeSelectCommentsDisabledForUri = (uri: string) =>
createSelector(selectCommentsDisabledChannelIds, makeSelectClaimForUri(uri), (commentsDisabledChannelIds, claim) => {
const channelClaim = !claim
? null
: claim.value_type === 'channel'
? claim
: claim.signing_channel && claim.is_channel_signature_valid
? claim.signing_channel
: null;
return channelClaim && channelClaim.claim_id && commentsDisabledChannelIds.includes(channelClaim.claim_id);
});

View file

@ -1,15 +1,15 @@
import { createSelector } from 'reselect';
import REWARDS from 'rewards';
const selectState = state => state.rewards || {};
const selectState = (state) => state.rewards || {};
export const selectUnclaimedRewardsByType = createSelector(selectState, state => state.unclaimedRewardsByType);
export const selectUnclaimedRewardsByType = createSelector(selectState, (state) => state.unclaimedRewardsByType);
export const selectClaimedRewardsById = createSelector(selectState, state => state.claimedRewardsById);
export const selectClaimedRewardsById = createSelector(selectState, (state) => state.claimedRewardsById);
export const selectClaimedRewards = createSelector(selectClaimedRewardsById, byId => Object.values(byId) || []);
export const selectClaimedRewards = createSelector(selectClaimedRewardsById, (byId) => Object.values(byId) || []);
export const selectClaimedRewardsByTransactionId = createSelector(selectClaimedRewards, rewards =>
export const selectClaimedRewardsByTransactionId = createSelector(selectClaimedRewards, (rewards) =>
rewards.reduce((mapParam, reward) => {
const map = mapParam;
map[reward.transaction_id] = reward;
@ -17,47 +17,58 @@ export const selectClaimedRewardsByTransactionId = createSelector(selectClaimedR
}, {})
);
export const selectUnclaimedRewards = createSelector(selectState, state => state.unclaimedRewards);
export const selectUnclaimedRewards = createSelector(selectState, (state) => state.unclaimedRewards);
export const selectFetchingRewards = createSelector(selectState, state => !!state.fetching);
export const selectFetchingRewards = createSelector(selectState, (state) => !!state.fetching);
export const selectUnclaimedRewardValue = createSelector(selectUnclaimedRewards, rewards =>
export const selectUnclaimedRewardValue = createSelector(selectUnclaimedRewards, (rewards) =>
rewards.reduce((sum, reward) => sum + reward.reward_amount, 0)
);
export const selectClaimsPendingByType = createSelector(selectState, state => state.claimPendingByType);
export const selectClaimsPendingByType = createSelector(selectState, (state) => state.claimPendingByType);
const selectIsClaimRewardPending = (state, props) => selectClaimsPendingByType(state, props)[props.reward_type];
export const makeSelectIsRewardClaimPending = () =>
createSelector(selectIsClaimRewardPending, isClaiming => isClaiming);
createSelector(selectIsClaimRewardPending, (isClaiming) => isClaiming);
export const selectClaimErrorsByType = createSelector(selectState, state => state.claimErrorsByType);
export const selectClaimErrorsByType = createSelector(selectState, (state) => state.claimErrorsByType);
const selectClaimRewardError = (state, props) => selectClaimErrorsByType(state, props)[props.reward_type];
export const makeSelectClaimRewardError = () => createSelector(selectClaimRewardError, errorMessage => errorMessage);
export const makeSelectClaimRewardError = () => createSelector(selectClaimRewardError, (errorMessage) => errorMessage);
const selectRewardByType = (state, rewardType) =>
selectUnclaimedRewards(state).find(reward => reward.reward_type === rewardType);
selectUnclaimedRewards(state).find((reward) => reward.reward_type === rewardType);
export const makeSelectRewardByType = () => createSelector(selectRewardByType, reward => reward);
export const makeSelectRewardByType = () => createSelector(selectRewardByType, (reward) => reward);
const selectRewardByClaimCode = (state, claimCode) =>
selectUnclaimedRewards(state).find(reward => reward.claim_code === claimCode);
selectUnclaimedRewards(state).find((reward) => reward.claim_code === claimCode);
export const makeSelectRewardByClaimCode = () => createSelector(selectRewardByClaimCode, reward => reward);
export const makeSelectRewardByClaimCode = () => createSelector(selectRewardByClaimCode, (reward) => reward);
export const makeSelectRewardAmountByType = () =>
createSelector(selectRewardByType, reward => (reward ? reward.reward_amount : 0));
createSelector(selectRewardByType, (reward) => (reward ? reward.reward_amount : 0));
export const selectRewardContentClaimIds = createSelector(selectState, state => state.rewardedContentClaimIds);
export const selectRewardContentClaimIds = createSelector(selectState, (state) => state.rewardedContentClaimIds);
export const selectReferralReward = createSelector(
selectUnclaimedRewards,
unclaimedRewards => unclaimedRewards.filter(reward => reward.reward_type === REWARDS.TYPE_REFERRAL)[0]
(unclaimedRewards) => unclaimedRewards.filter((reward) => reward.reward_type === REWARDS.TYPE_REFERRAL)[0]
);
export const selectHasUnclaimedRefereeReward = createSelector(selectUnclaimedRewards, unclaimedRewards =>
unclaimedRewards.some(reward => reward.reward_type === REWARDS.TYPE_REFEREE)
export const selectHasUnclaimedRefereeReward = createSelector(selectUnclaimedRewards, (unclaimedRewards) =>
unclaimedRewards.some((reward) => reward.reward_type === REWARDS.TYPE_REFEREE)
);
export const selectIsClaimingInitialRewards = createSelector(selectClaimsPendingByType, (claimsPendingByType) => {
return !!(claimsPendingByType[REWARDS.TYPE_NEW_USER] || claimsPendingByType[REWARDS.TYPE_CONFIRM_EMAIL]);
});
export const selectHasClaimedInitialRewards = createSelector(selectClaimedRewardsById, (claimedRewardsById) => {
const claims = Object.values(claimedRewardsById);
const newUserClaimed = !!claims.find((claim) => claim && claim.reward_type === REWARDS.TYPE_NEW_USER);
const confirmEmailClaimed = !!claims.find((claim) => claim && claim.reward_type === REWARDS.TYPE_CONFIRM_EMAIL);
return newUserClaimed && confirmEmailClaimed;
});

View file

@ -4,6 +4,7 @@ import { selectShowMatureContent } from 'redux/selectors/settings';
import {
parseURI,
makeSelectClaimForUri,
makeSelectClaimForClaimId,
makeSelectClaimIsNsfw,
buildURI,
isClaimNsfw,
@ -26,9 +27,9 @@ export const selectSearchOptions: (state: State) => SearchOptions = createSelect
export const selectIsSearching: (state: State) => boolean = createSelector(selectState, (state) => state.searching);
export const selectSearchUrisByQuery: (state: State) => { [string]: Array<string> } = createSelector(
export const selectSearchResultByQuery: (state: State) => { [string]: Array<string> } = createSelector(
selectState,
(state) => state.urisByQuery
(state) => state.resultsByQuery
);
export const selectHasReachedMaxResultsLength: (state: State) => { [boolean]: Array<boolean> } = createSelector(
@ -36,15 +37,15 @@ export const selectHasReachedMaxResultsLength: (state: State) => { [boolean]: Ar
(state) => state.hasReachedMaxResultsLength
);
export const makeSelectSearchUris = (query: string): ((state: State) => Array<string>) =>
export const makeSelectSearchUrisForQuery = (query: string): ((state: State) => Array<string>) =>
// replace statement below is kind of ugly, and repeated in doSearch action
createSelector(selectSearchUrisByQuery, (byQuery) => {
createSelector(selectSearchResultByQuery, (byQuery) => {
if (query) {
query = query.replace(/^lbry:\/\//i, '').replace(/\//, ' ');
const normalizedQuery = createNormalizedSearchKey(query);
return byQuery[normalizedQuery];
return byQuery[normalizedQuery] && byQuery[normalizedQuery]['uris'];
}
return byQuery[query];
return byQuery[query] && byQuery[query]['uris'];
});
export const makeSelectHasReachedMaxResultsLength = (query: string): ((state: State) => boolean) =>
@ -60,7 +61,7 @@ export const makeSelectHasReachedMaxResultsLength = (query: string): ((state: St
export const makeSelectRecommendedContentForUri = (uri: string) =>
createSelector(
makeSelectClaimForUri(uri),
selectSearchUrisByQuery,
selectSearchResultByQuery,
makeSelectClaimIsNsfw(uri),
(claim, searchUrisByQuery, isMature) => {
let recommendedContent;
@ -84,16 +85,47 @@ export const makeSelectRecommendedContentForUri = (uri: string) =>
const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options);
const normalizedSearchQuery = createNormalizedSearchKey(searchQuery);
let searchUris = searchUrisByQuery[normalizedSearchQuery];
if (searchUris) {
searchUris = searchUris.filter((searchUri) => searchUri !== currentUri);
recommendedContent = searchUris;
let searchResult = searchUrisByQuery[normalizedSearchQuery];
if (searchResult) {
recommendedContent = searchResult['uris'].filter((searchUri) => searchUri !== currentUri);
}
}
return recommendedContent;
}
);
export const makeSelectRecommendedRecsysIdForClaimId = (claimId: string) =>
createSelector(makeSelectClaimForClaimId(claimId), selectSearchResultByQuery, (claim, searchUrisByQuery) => {
// TODO: DRY this out.
let poweredBy;
if (claim) {
const isMature = isClaimNsfw(claim);
const { title } = claim.value;
if (!title) {
return;
}
const options: {
related_to?: string,
nsfw?: boolean,
isBackgroundSearch?: boolean,
} = { related_to: claim.claim_id, isBackgroundSearch: true };
options['nsfw'] = isMature;
const searchQuery = getSearchQueryString(title.replace(/\//, ' '), options);
const normalizedSearchQuery = createNormalizedSearchKey(searchQuery);
let searchResult = searchUrisByQuery[normalizedSearchQuery];
if (searchResult) {
poweredBy = searchResult.recsys;
} else {
return normalizedSearchQuery;
}
}
return poweredBy;
});
export const makeSelectWinningUriForQuery = (query: string) => {
const uriFromQuery = `lbry://${query}`;

View file

@ -690,7 +690,8 @@
}
}
.button--file-action {
.button--file-action,
.download-text {
display: block;
margin: 0 0;
padding: var(--spacing-xxs) var(--spacing-xxs);

View file

@ -217,12 +217,35 @@ $thumbnailWidthSmall: 1rem;
.comment__pin {
margin-left: var(--spacing-s);
font-size: var(--font-xsmall);
.icon {
padding-top: 1px;
}
}
.comment__badge {
padding-right: var(--spacing-xxs);
.icon {
margin-bottom: -3px;
}
}
.comment__badge--global-mod {
.st0 {
// @see: ICONS.BADGE_MOD
fill: #fe7500;
}
}
.comment__badge--mod {
.st0 {
// @see: ICONS.BADGE_MOD
fill: #ff3850;
}
}
.comment__message {
word-break: break-word;
max-width: 35rem;
@ -437,3 +460,9 @@ $thumbnailWidthSmall: 1rem;
.comment--blocked {
opacity: 0.5;
}
.comment--min-amount-notice {
.icon {
margin-bottom: -3px; // TODO fix few instances of these (find "-2px")
}
}

View file

@ -223,6 +223,27 @@ $discussion-header__height: 3rem;
}
}
.livestream-pinned__wrapper {
flex-shrink: 0;
position: relative;
padding: var(--spacing-s) var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
font-size: var(--font-small);
background-color: var(--color-card-background-highlighted);
width: 100%;
.livestream-comment {
padding-top: var(--spacing-xs);
max-height: 6rem;
overflow-y: scroll;
}
@media (min-width: $breakpoint-small) {
padding: var(--spacing-xs);
width: var(--livestream-comments-width);
}
}
.livestream-superchat__amount-large {
.credit-amount {
display: flex;

View file

@ -42,6 +42,7 @@ $contentMaxWidth: 60rem;
width: 100%;
display: flex;
padding: var(--spacing-m) 0;
justify-content: space-between;
.channel-thumbnail {
@include handleChannelGif(3rem);
@ -60,6 +61,7 @@ $contentMaxWidth: 60rem;
.notification__wrapper--unread {
background-color: var(--color-card-background-highlighted);
justify-content: space-between;
&:hover {
background-color: var(--color-button-secondary-bg);
@ -120,6 +122,7 @@ $contentMaxWidth: 60rem;
}
.notification__title {
position: relative;
font-size: var(--font-small);
color: var(--color-text);
margin-bottom: var(--spacing-s);
@ -135,6 +138,7 @@ $contentMaxWidth: 60rem;
.notification__text {
font-size: var(--font-body);
color: var(--color-text);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;

View file

@ -340,3 +340,7 @@ pre {
//.successCard, .toConfirmCard {
// max-width: 94%
//}
.stripe__complete-verification-button {
margin-right: 10px !important;
}

11
ui/util/claim.js Normal file
View file

@ -0,0 +1,11 @@
// @flow
export function getChannelIdFromClaim(claim: ?Claim) {
if (claim) {
if (claim.value_type === 'channel') {
return claim.claim_id;
} else if (claim.signing_channel) {
return claim.signing_channel.claim_id;
}
}
}

View file

@ -1,4 +1,9 @@
// @flow
export default function handleFetchResponse(response: Response): Promise<any> {
return response.status === 200 ? Promise.resolve(response.json()) : Promise.reject(new Error(response.statusText));
const headers = response.headers;
const poweredBy = headers.get('x-powered-by');
return response.status === 200
? response.json().then((body) => ({ body, poweredBy }))
: Promise.reject(new Error(response.statusText));
}

13
ui/util/stripe.js Normal file
View file

@ -0,0 +1,13 @@
// @flow
import { STRIPE_PUBLIC_KEY } from 'config';
import * as STRIPE_CONSTS from 'constants/stripe';
export function getStripeEnvironment() {
if (STRIPE_PUBLIC_KEY) {
if (STRIPE_PUBLIC_KEY.indexOf('pk_live') > -1) {
return STRIPE_CONSTS.LIVE;
} else {
return STRIPE_CONSTS.TEST;
}
}
return null;
}

View file

@ -3589,12 +3589,7 @@ minimatch@^3.0.4:
dependencies:
brace-expansion "^1.1.7"
minimist@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
minimist@^1.2.5:
minimist@^1.2.0, minimist@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==