hyperchats

This commit is contained in:
Sean Yesmunt 2021-04-23 15:59:48 -04:00
parent bd62a55608
commit 6d89f0df7e
66 changed files with 1594 additions and 433 deletions

35
flow-typed/Comment.js vendored
View file

@ -12,11 +12,13 @@ declare type Comment = {
is_channel_signature_valid?: boolean, // whether or not the signature could be validated
parent_id?: number, // comment_id of comment this is in reply to
is_pinned: boolean,
support_amount: number,
};
// todo: relate individual comments to their commentId
declare type CommentsState = {
commentsByUri: { [string]: string },
superChatsByUri: { [string]: { totalAmount: number, comments: Array<Comment> } },
byId: { [string]: Array<string> },
repliesByParentId: { [string]: Array<string> }, // ParentCommentID -> list of reply comments
topLevelCommentsById: { [string]: Array<string> }, // ClaimID -> list of top level comments
@ -41,3 +43,36 @@ declare type CommentReactParams = {
clear_types?: string,
remove?: boolean,
};
// @flow
declare type CommentListParams = {
page: number,
page_size: number,
claim_id: string,
};
declare type CommentListResponse = {
items: Array<Comment>,
total_amount: number,
};
declare type CommentAbandonParams = {
comment_id: string,
creator_channel_id?: string,
creator_channel_name?: string,
channel_id?: string,
hexdata?: string,
};
declare type CommentCreateParams = {
comment: string,
claim_id: string,
parent_id?: string,
signature: string,
signing_ts: number,
support_tx_id?: string,
};
declare type SuperListParams = {};
declare type ModerationBlockParams = {};

View file

@ -1,16 +0,0 @@
// @flow
declare type CommentListParams = {
page: number,
page_size: number,
claim_id: string,
};
declare type CommentAbandonParams = {
comment_id: string,
creator_channel_id?: string,
creator_channel_name?: string,
channel_id?: string,
hexdata?: string,
};
declare type ModerationBlockParams = {};

1
flow-typed/user.js vendored
View file

@ -30,4 +30,5 @@ declare type User = {
experimental_ui: boolean,
odysee_live_enabled: boolean,
odysee_live_disabled: boolean,
global_mod: boolean,
};

View file

@ -142,7 +142,7 @@
"imagesloaded": "^4.1.4",
"json-loader": "^0.5.4",
"lbry-format": "https://github.com/lbryio/lbry-format.git",
"lbry-redux": "lbryio/lbry-redux#eb37009a987410a60e9f2ba79708049c9904687c",
"lbry-redux": "lbryio/lbry-redux#7e173446838b381491492526ff29ca8312819879",
"lbryinc": "lbryio/lbryinc#8f9a58bfc8312a65614fd7327661cdcc502c4e59",
"lint-staged": "^7.0.2",
"localforage": "^1.7.1",

View file

@ -10,6 +10,8 @@ const Comments = {
moderation_block_list: (params: ModerationBlockParams) => fetchCommentsApi('moderation.BlockedList', params),
comment_list: (params: CommentListParams) => fetchCommentsApi('comment.List', params),
comment_abandon: (params: CommentAbandonParams) => fetchCommentsApi('comment.Abandon', params),
comment_create: (params: CommentCreateParams) => fetchCommentsApi('comment.Create', params),
super_list: (params: SuperListParams) => fetchCommentsApi('comment.SuperChatList', params),
};
function fetchCommentsApi(method: string, params: {}) {

View file

@ -19,6 +19,7 @@ type Props = {
isResolving: boolean,
showDelayedMessage?: boolean,
hideStakedIndicator?: boolean,
xsmall?: boolean,
};
function ChannelThumbnail(props: Props) {
@ -29,6 +30,7 @@ function ChannelThumbnail(props: Props) {
thumbnailPreview: rawThumbnailPreview,
obscure,
small = false,
xsmall = false,
allowGifs = false,
claim,
doResolveUri,
@ -72,6 +74,7 @@ function ChannelThumbnail(props: Props) {
className={classnames('channel-thumbnail', className, {
[colorClassName]: !showThumb,
'channel-thumbnail--small': small,
'channel-thumbnail--xsmall': xsmall,
'channel-thumbnail--resolving': isResolving,
})}
>

View file

@ -30,12 +30,12 @@ type Props = {
persistedStorageKey?: string,
showHiddenByUser: boolean,
showUnresolvedClaims?: boolean,
renderProperties: ?(Claim) => Node,
renderActions?: (Claim) => ?Node,
renderProperties?: (Claim) => ?Node,
includeSupportAction?: boolean,
injectedItem: ?Node,
timedOutMessage?: Node,
tileLayout?: boolean,
renderActions?: (Claim) => ?Node,
searchInLanguage: boolean,
hideMenu?: boolean,
};
@ -55,12 +55,12 @@ export default function ClaimList(props: Props) {
page,
showHiddenByUser,
showUnresolvedClaims,
renderProperties,
includeSupportAction,
injectedItem,
timedOutMessage,
tileLayout = false,
renderActions,
renderProperties,
searchInLanguage,
hideMenu,
} = props;
@ -101,7 +101,9 @@ export default function ClaimList(props: Props) {
return tileLayout && !header ? (
<section className="claim-grid">
{urisLength > 0 &&
uris.map((uri) => <ClaimPreviewTile key={uri} uri={uri} showHiddenByUser={showHiddenByUser} />)}
uris.map((uri) => (
<ClaimPreviewTile key={uri} uri={uri} showHiddenByUser={showHiddenByUser} properties={renderProperties} />
))}
{!timedOut && urisLength === 0 && !loading && <div className="empty main--empty">{empty || noResultMsg}</div>}
{timedOut && timedOutMessage && <div className="empty main--empty">{timedOutMessage}</div>}
</section>

View file

@ -301,10 +301,6 @@ const ClaimPreview = forwardRef<any, {}>((props: Props, ref: any) => {
<ClaimPreviewTitle uri={uri} />
</NavLink>
)}
{/* {type !== 'small' && !isChannelUri && signingChannel && SIMPLE_SITE && (
<ChannelThumbnail uri={signingChannel.permanent_url} />
)} */}
</div>
<ClaimPreviewSubtitle uri={uri} type={type} />
{(pending || !!reflectingProgress) && <PublishPending uri={uri} />}

View file

@ -40,6 +40,7 @@ type Props = {
isMature: boolean,
showMature: boolean,
showHiddenByUser?: boolean,
properties?: (Claim) => void,
};
function ClaimPreviewTile(props: Props) {
@ -60,6 +61,7 @@ function ClaimPreviewTile(props: Props) {
isMature,
showMature,
showHiddenByUser,
properties,
} = props;
const isRepost = claim && claim.repost_channel_url;
const shouldFetch = claim === undefined;
@ -171,7 +173,7 @@ function ClaimPreviewTile(props: Props) {
</div>
{/* @endif */}
<div className="claim-preview__file-property-overlay">
<FileProperties uri={uri} small />
<FileProperties uri={uri} small properties={properties} />
</div>
</React.Fragment>
)}

View file

@ -1,6 +1,7 @@
// @flow
import { ENABLE_NO_SOURCE_CLAIMS, SIMPLE_SITE } from 'config';
import * as CS from 'constants/claim_search';
import type { Node } from 'react';
import React from 'react';
import { createNormalizedClaimSearchKey, MATURE_TAGS } from 'lbry-redux';
import ClaimPreviewTile from 'component/claimPreviewTile';
@ -34,6 +35,8 @@ type Props = {
timestamp?: string,
feeAmount?: string,
limitClaimsPerChannel?: number,
hasNoSource?: boolean,
renderProperties?: (Claim) => ?Node,
};
function ClaimTilesDiscover(props: Props) {
@ -57,6 +60,8 @@ function ClaimTilesDiscover(props: Props) {
feeAmount,
limitClaimsPerChannel,
fetchingClaimSearchByQuery,
hasNoSource,
renderProperties,
} = props;
const { location } = useHistory();
const urlParams = new URLSearchParams(location.search);
@ -95,7 +100,9 @@ function ClaimTilesDiscover(props: Props) {
stream_types: streamTypes === null ? undefined : SIMPLE_SITE ? [CS.FILE_VIDEO, CS.FILE_AUDIO] : undefined,
};
if (!ENABLE_NO_SOURCE_CLAIMS && (!claimType || claimType === 'stream')) {
if (ENABLE_NO_SOURCE_CLAIMS && hasNoSource) {
options.has_no_source = true;
} else if (!ENABLE_NO_SOURCE_CLAIMS && (!claimType || claimType === 'stream')) {
options.has_source = true;
}
@ -149,7 +156,7 @@ function ClaimTilesDiscover(props: Props) {
return (
<ul className="claim-grid">
{uris && uris.length
? uris.map((uri) => <ClaimPreviewTile key={uri} uri={uri} />)
? uris.map((uri) => <ClaimPreviewTile key={uri} uri={uri} properties={renderProperties} />)
: new Array(pageSize).fill(1).map((x, i) => <ClaimPreviewTile key={i} placeholder />)}
</ul>
);

View file

@ -21,6 +21,7 @@ import { useHistory } from 'react-router';
import CommentCreate from 'component/commentCreate';
import CommentMenuList from 'component/commentMenuList';
import UriIndicator from 'component/uriIndicator';
import CreditAmount from 'component/common/credit-amount';
type Props = {
clearPlayingUri: () => void,
@ -51,7 +52,7 @@ type Props = {
activeChannelClaim: ?ChannelClaim,
playingUri: ?PlayingUri,
stakedLevel: number,
livestream?: boolean,
supportAmount: number,
};
const LENGTH_TO_COLLAPSE = 300;
@ -80,7 +81,7 @@ function Comment(props: Props) {
othersReacts,
playingUri,
stakedLevel,
livestream,
supportAmount,
} = props;
const {
push,
@ -167,7 +168,7 @@ function Comment(props: Props) {
className={classnames('comment', {
'comment--top-level': isTopLevel,
'comment--reply': !isTopLevel,
'comment--livestream': livestream,
'comment--superchat': supportAmount > 0,
})}
id={commentId}
onMouseOver={() => setMouseHover(true)}
@ -179,22 +180,15 @@ function Comment(props: Props) {
'comment--slimed': slimedToDeath && !displayDeadComment,
})}
>
{!livestream && (
<div className="comment__thumbnail-wrapper">
{authorUri ? (
<ChannelThumbnail
uri={authorUri}
obscure={channelIsBlocked}
small
className="comment__author-thumbnail"
/>
) : (
<ChannelThumbnail small className="comment__author-thumbnail" />
)}
</div>
)}
<div className="comment__thumbnail-wrapper">
{authorUri ? (
<ChannelThumbnail uri={authorUri} obscure={channelIsBlocked} small className="comment__author-thumbnail" />
) : (
<ChannelThumbnail small className="comment__author-thumbnail" />
)}
</div>
<div className="comment__body_container">
<div className="comment__body-container">
<div className="comment__meta">
<div className="comment__meta-information">
{!author ? (
@ -205,17 +199,16 @@ function Comment(props: Props) {
'comment__author--creator': commentByOwnerOfContent,
})}
link
external={livestream}
uri={authorUri}
/>
)}
{!livestream && (
<Button
className="comment__time"
onClick={handleTimeClick}
label={<DateTime date={timePosted} timeAgo />}
/>
)}
<Button
className="comment__time"
onClick={handleTimeClick}
label={<DateTime date={timePosted} timeAgo />}
/>
{supportAmount > 0 && <CreditAmount amount={supportAmount} superChatLight size={12} />}
{isPinned && (
<span className="comment__pin">
@ -251,6 +244,7 @@ function Comment(props: Props) {
{isEditing ? (
<Form onSubmit={handleSubmit}>
<FormField
className="comment__edit-input"
type={!SIMPLE_SITE && advancedEditor ? 'markdown' : 'textarea'}
name="editing_comment"
value={editedMessage}
@ -258,7 +252,7 @@ function Comment(props: Props) {
onChange={handleEditMessageChanged}
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT}
/>
<div className="section__actions">
<div className="section__actions section__actions--no-margin">
<Button
button="primary"
type="submit"
@ -295,20 +289,18 @@ function Comment(props: Props) {
)}
</div>
{!livestream && (
<div className="comment__actions">
{threadDepth !== 0 && (
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Log in to reply')}
className="comment__action"
onClick={handleCommentReply}
icon={ICONS.REPLY}
/>
)}
{ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />}
</div>
)}
<div className="comment__actions">
{threadDepth !== 0 && (
<Button
requiresAuth={IS_WEB}
label={commentingEnabled ? __('Reply') : __('Log in to reply')}
className="comment__action"
onClick={handleCommentReply}
icon={ICONS.REPLY}
/>
)}
{ENABLE_COMMENT_REACTIONS && <CommentReactions uri={uri} commentId={commentId} />}
</div>
{isReplying && (
<CommentCreate

View file

@ -4,8 +4,8 @@ import {
makeSelectClaimIsMine,
selectMyChannelClaims,
selectFetchingMyChannels,
doSendTip,
} from 'lbry-redux';
import { selectIsPostingComment } from 'redux/selectors/comments';
import { doOpenModal, doSetActiveChannel } from 'redux/actions/app';
import { doCommentCreate } from 'redux/actions/comments';
import { selectUserVerifiedEmail } from 'redux/selectors/user';
@ -18,17 +18,17 @@ const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
channels: selectMyChannelClaims(state),
isFetchingChannels: selectFetchingMyChannels(state),
isPostingComment: selectIsPostingComment(state),
activeChannelClaim: selectActiveChannelClaim(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),
});
const perform = (dispatch, ownProps) => ({
createComment: (comment, claimId, parentId) =>
dispatch(doCommentCreate(comment, claimId, parentId, ownProps.uri, ownProps.livestream)),
createComment: (comment, claimId, parentId, txid) =>
dispatch(doCommentCreate(comment, claimId, parentId, ownProps.uri, ownProps.livestream, txid)),
openModal: (modal, props) => dispatch(doOpenModal(modal, props)),
setActiveChannel: (claimId) => dispatch(doSetActiveChannel(claimId)),
toast: (message) => dispatch(doToast({ message, isError: true })),
sendTip: (params, callback, errorCallback) => dispatch(doSendTip(params, false, callback, errorCallback, false)),
});
export default connect(select, perform)(CommentCreate);

View file

@ -1,25 +1,22 @@
// @flow
import type { ElementRef } from 'react';
import { SIMPLE_SITE } from 'config';
import * as PAGES from 'constants/pages';
import React, { useEffect, useState } from 'react';
import * as ICONS from 'constants/icons';
import React from 'react';
import classnames from 'classnames';
import { FormField, Form } from 'component/common/form';
import Button from 'component/button';
import SelectChannel from 'component/selectChannel';
import usePersistedState from 'effects/use-persisted-state';
import { FF_MAX_CHARS_IN_COMMENT } from 'constants/form-field';
import { FF_MAX_CHARS_IN_COMMENT, FF_MAX_CHARS_IN_LIVESTREAM_COMMENT } from 'constants/form-field';
import { useHistory } from 'react-router';
import type { ElementRef } from 'react';
import emoji from 'emoji-dictionary';
import WalletTipAmountSelector from 'component/walletTipAmountSelector';
import CreditAmount from 'component/common/credit-amount';
import ChannelThumbnail from 'component/channelThumbnail';
import UriIndicator from 'component/uriIndicator';
const COMMENT_SLOW_MODE_SECONDS = 5;
const LIVESTREAM_EMOJIS = [
emoji.getUnicode('rocket'),
emoji.getUnicode('jeans'),
emoji.getUnicode('fire'),
emoji.getUnicode('heart'),
emoji.getUnicode('open_mouth'),
];
type Props = {
uri: string,
@ -32,12 +29,12 @@ type Props = {
isFetchingChannels: boolean,
parentId: string,
isReply: boolean,
isPostingComment: boolean,
activeChannel: string,
activeChannelClaim: ?ChannelClaim,
livestream?: boolean,
toast: (string) => void,
claimIsMine: boolean,
sendTip: ({}, (any) => void, (any) => void) => void,
};
export function CommentCreate(props: Props) {
@ -51,24 +48,28 @@ export function CommentCreate(props: Props) {
isFetchingChannels,
isReply,
parentId,
isPostingComment,
activeChannelClaim,
livestream,
toast,
claimIsMine,
sendTip,
} = props;
const buttonref: ElementRef<any> = React.useRef();
const {
push,
location: { pathname },
} = useHistory();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const { claim_id: claimId } = claim;
const [isSupportComment, setIsSupportComment] = React.useState();
const [isReviewingSupportComment, setIsReviewingSupportComment] = React.useState();
const [tipAmount, setTipAmount] = React.useState(1);
const [commentValue, setCommentValue] = React.useState('');
const [lastCommentTime, setLastCommentTime] = React.useState();
const [charCount, setCharCount] = useState(commentValue.length);
const [advancedEditor, setAdvancedEditor] = usePersistedState('comment-editor-mode', false);
const hasChannels = channels && channels.length;
const disabled = isPostingComment || !activeChannelClaim || !commentValue.length;
const disabled = isSubmitting || !activeChannelClaim || !commentValue.length;
const charCount = commentValue.length;
function handleCommentChange(event) {
let commentValue;
@ -108,25 +109,63 @@ export function CommentCreate(props: Props) {
return;
}
createComment(commentValue, claimId, parentId).then((res) => {
handleCreateComment();
}
}
function handleSupportComment() {
if (!activeChannelClaim) {
return;
}
const params = {
amount: tipAmount,
claim_id: claimId,
channel_id: activeChannelClaim.claim_id,
};
setIsSubmitting(true);
sendTip(
params,
(response) => {
const { txid } = response;
setTimeout(() => {
handleCreateComment(txid);
}, 1500);
},
() => {
setIsSubmitting(false);
}
);
}
function handleCreateComment(txid) {
setIsSubmitting(true);
createComment(commentValue, claimId, parentId, txid)
.then((res) => {
setIsSubmitting(false);
if (res && res.signature) {
setCommentValue('');
setLastCommentTime(Date.now());
setIsReviewingSupportComment(false);
setIsSupportComment(false);
if (onDoneReplying) {
onDoneReplying();
}
}
})
.catch(() => {
setIsSubmitting(false);
});
}
}
function toggleEditorMode() {
setAdvancedEditor(!advancedEditor);
}
useEffect(() => setCharCount(commentValue.length), [commentValue]);
if (!hasChannels) {
return (
<div
@ -146,13 +185,39 @@ export function CommentCreate(props: Props) {
placeholder={__('Say something about this...')}
label={isFetchingChannels ? __('Comment') : undefined}
/>
<div className="section__actions">
<div className="section__actions--no-margin">
<Button disabled button="primary" label={__('Post --[button to submit something]--')} requiresAuth={IS_WEB} />
</div>
</div>
);
}
if (isReviewingSupportComment && activeChannelClaim) {
return (
<div className="comment__create">
<div className="comment__sc-preview">
<CreditAmount className="comment__scpreview-amount" amount={tipAmount} size={18} />
<ChannelThumbnail xsmall uri={activeChannelClaim.canonical_url} />
<div>
<UriIndicator uri={activeChannelClaim.name} link />
<div>{commentValue}</div>
</div>
</div>
<div className="section__actions--no-margin">
<Button
autoFocus
button="primary"
disabled={disabled}
label={isSubmitting ? __('Sending...') : __('Send')}
onClick={handleSupportComment}
/>
<Button button="link" label={__('Cancel')} onClick={() => setIsReviewingSupportComment(false)} />
</div>
</div>
);
}
return (
<Form
onSubmit={handleSubmit}
@ -167,7 +232,9 @@ export function CommentCreate(props: Props) {
name={isReply ? 'content_reply' : 'content_description'}
label={
<span className="comment-new__label-wrapper">
<div className="comment-new__label">{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}</div>
{!livestream && (
<div className="comment-new__label">{isReply ? __('Replying as') + ' ' : __('Comment as') + ' '}</div>
)}
<SelectChannel tiny />
</span>
}
@ -182,52 +249,56 @@ export function CommentCreate(props: Props) {
charCount={charCount}
onChange={handleCommentChange}
autoFocus={isReply}
textAreaMaxLength={FF_MAX_CHARS_IN_COMMENT}
textAreaMaxLength={livestream ? FF_MAX_CHARS_IN_LIVESTREAM_COMMENT : FF_MAX_CHARS_IN_COMMENT}
/>
{livestream && hasChannels && (
<div className="livestream__emoji-actions">
{LIVESTREAM_EMOJIS.map((emoji) => (
<Button
key={emoji}
disabled={isPostingComment}
type="button"
button="alt"
className="button--emoji"
label={emoji}
onClick={() => {
setCommentValue(commentValue ? `${commentValue} ${emoji}` : emoji);
}}
/>
))}
</div>
)}
{isSupportComment && <WalletTipAmountSelector amount={tipAmount} onChange={(amount) => setTipAmount(amount)} />}
<div className="section__actions section__actions--no-margin">
<Button
ref={buttonref}
button="primary"
disabled={disabled}
type="submit"
label={
isReply
? isPostingComment
? __('Replying...')
: __('Reply')
: isPostingComment
? __('Posting...')
: __('Post --[button to submit something]--')
}
requiresAuth={IS_WEB}
/>
{isReply && (
<Button
button="link"
label={__('Cancel')}
onClick={() => {
if (onCancelReplying) {
onCancelReplying();
{isSupportComment ? (
<>
<Button
disabled={disabled}
type="button"
button="primary"
icon={ICONS.LBC}
label={__('Review')}
onClick={() => setIsReviewingSupportComment(true)}
/>
<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}
/>
{!claimIsMine && (
<Button disabled={disabled} button="alt" icon={ICONS.LBC} onClick={() => setIsSupportComment(true)} />
)}
{isReply && (
<Button
button="link"
label={__('Cancel')}
onClick={() => {
if (onCancelReplying) {
onCancelReplying();
}
}}
/>
)}
</>
)}
</div>
</Form>

View file

@ -16,8 +16,8 @@ import Empty from 'component/common/empty';
type Props = {
comments: Array<Comment>,
fetchComments: string => void,
fetchReacts: string => Promise<any>,
fetchComments: (string) => void,
fetchReacts: (string) => Promise<any>,
uri: string,
claimIsMine: boolean,
myChannels: ?Array<ChannelClaim>,
@ -129,11 +129,11 @@ function CommentList(props: Props) {
if (linkedComment) {
if (!linkedComment.parent_id) {
orderedComments = arrayOfComments.filter(c => c.comment_id !== linkedComment.comment_id);
orderedComments = arrayOfComments.filter((c) => c.comment_id !== linkedComment.comment_id);
orderedComments.unshift(linkedComment);
} else {
const parentComment = arrayOfComments.find(c => c.comment_id === linkedComment.parent_id);
orderedComments = arrayOfComments.filter(c => c.comment_id !== linkedComment.parent_id);
const parentComment = arrayOfComments.find((c) => c.comment_id === linkedComment.parent_id);
orderedComments = arrayOfComments.filter((c) => c.comment_id !== linkedComment.parent_id);
if (parentComment) {
orderedComments.unshift(parentComment);
@ -218,7 +218,7 @@ function CommentList(props: Props) {
<ul className="comments" ref={commentRef}>
{comments &&
displayedComments &&
displayedComments.map(comment => {
displayedComments.map((comment) => {
return (
<CommentView
isTopLevel
@ -235,6 +235,7 @@ function CommentList(props: Props) {
commentIsMine={comment.channel_id && isMyComment(comment.channel_id)}
linkedComment={linkedComment}
isPinned={comment.is_pinned}
supportAmount={comment.support_amount}
/>
);
})}

View file

@ -22,7 +22,7 @@ function CommentsReplies(props: Props) {
const sortedComments = comments ? [...comments].reverse() : [];
const numberOfComments = comments ? comments.length : 0;
const linkedCommentId = linkedComment ? linkedComment.comment_id : '';
const commentsIndexOfLInked = comments && sortedComments.findIndex(e => e.comment_id === linkedCommentId);
const commentsIndexOfLInked = comments && sortedComments.findIndex((e) => e.comment_id === linkedCommentId);
function showMore() {
if (start > 0) {
@ -105,6 +105,7 @@ function CommentsReplies(props: Props) {
linkedComment={linkedComment}
commentingEnabled={commentingEnabled}
handleCommentDone={handleCommentDone}
supportAmount={comment.support_amount}
/>
);
})}

View file

@ -47,10 +47,12 @@ export default function Card(props: Props) {
return (
<section
role="button"
className={classnames(className, 'card')}
onClick={() => {
onClick={(e) => {
if (onClick) {
onClick();
e.stopPropagation();
}
}}
>

View file

@ -15,6 +15,9 @@ type Props = {
fee?: boolean,
className?: string,
noFormat?: boolean,
size?: number,
superChat?: boolean,
superChatLight?: boolean,
};
class CreditAmount extends React.PureComponent<Props> {
@ -39,8 +42,10 @@ class CreditAmount extends React.PureComponent<Props> {
showLBC,
className,
noFormat,
size,
superChat,
superChatLight,
} = this.props;
const minimumRenderableAmount = 10 ** (-1 * precision);
const fullPrice = formatFullPrice(amount, 2);
const isFree = parseFloat(amount) === 0;
@ -66,7 +71,7 @@ class CreditAmount extends React.PureComponent<Props> {
}
if (showLBC) {
amountText = <LbcSymbol postfix={amountText} />;
amountText = <LbcSymbol postfix={amountText} size={size} />;
}
if (fee) {
@ -75,7 +80,13 @@ class CreditAmount extends React.PureComponent<Props> {
}
return (
<span title={fullPrice} className={classnames(className, {})}>
<span
title={fullPrice}
className={classnames(className, {
'super-chat': superChat,
'super-chat--light': superChatLight,
})}
>
<span className="credit-amount">{amountText}</span>
{isEstimate ? (

View file

@ -3,13 +3,13 @@ import * as React from 'react';
import { FormField } from './form-field';
type FormPrice = {
amount: ?number,
amount: number,
currency: string,
};
type Props = {
price: FormPrice,
onChange: FormPrice => void,
onChange: (FormPrice) => void,
placeholder: number,
min: number,
disabled: boolean,
@ -27,7 +27,7 @@ export class FormFieldPrice extends React.PureComponent<Props> {
handleAmountChange(event: SyntheticInputEvent<*>) {
const { price, onChange } = this.props;
const amount = event.target.value ? parseFloat(event.target.value) : undefined;
const amount = event.target.value ? parseFloat(event.target.value) : 0;
onChange({
currency: price.currency,
amount,
@ -54,7 +54,7 @@ export class FormFieldPrice extends React.PureComponent<Props> {
className="form-field--price-amount"
min={min}
value={price.amount}
onWheel={e => e.preventDefault()}
onWheel={(e) => e.preventDefault()}
onChange={this.handleAmountChange}
placeholder={placeholder || 5}
disabled={disabled}

View file

@ -8,6 +8,15 @@ import { openEditorMenu, stopContextMenu } from 'util/context-menu';
import { FF_MAX_CHARS_DEFAULT } from 'constants/form-field';
import 'easymde/dist/easymde.min.css';
import Button from 'component/button';
import emoji from 'emoji-dictionary';
const QUICK_EMOJIS = [
emoji.getUnicode('rocket'),
emoji.getUnicode('jeans'),
emoji.getUnicode('fire'),
emoji.getUnicode('heart'),
emoji.getUnicode('open_mouth'),
];
type Props = {
name: string,
@ -26,9 +35,6 @@ type Props = {
affixClass?: string, // class applied to prefix/postfix label
autoFocus?: boolean,
labelOnLeft: boolean,
inputProps?: {
disabled?: boolean,
},
inputButton?: React$Node,
blockWrap: boolean,
charCount?: number,
@ -38,6 +44,9 @@ type Props = {
max?: number,
quickActionLabel?: string,
quickActionHandler?: (any) => any,
disabled?: boolean,
onChange: (any) => void,
value?: string | number,
};
export class FormField extends React.PureComponent<Props> {
@ -262,7 +271,25 @@ export class FormField extends React.PureComponent<Props> {
ref={this.input}
{...inputProps}
/>
{countInfo}
<div className="form-field__textarea-info">
<div className="form-field__quick-emojis">
{QUICK_EMOJIS.map((emoji) => (
<Button
key={emoji}
disabled={inputProps.disabled}
type="button"
className="button--emoji"
label={emoji}
onClick={() => {
inputProps.onChange({
target: { value: inputProps.value ? `${inputProps.value} ${emoji}` : emoji },
});
}}
/>
))}
</div>
{countInfo}
</div>
</fieldset-section>
);
} else {

View file

@ -1336,7 +1336,7 @@ export const icons = {
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
viewBox="2 0 24 24"
width={props.size || '18'}
height={props.size || '18'}
fill="currentColor"
@ -1728,6 +1728,269 @@ export const icons = {
</g>
</svg>
),
[ICONS.LIVESTREAM]: (props: CustomProps) => (
<svg
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 36 36"
width={props.size || '18'}
height={props.size || '16'}
className={props.className}
>
<g id="XMLID_505_">
<linearGradient
id="XMLID_420_"
gradientUnits="userSpaceOnUse"
x1="-519.065"
y1="1525.4059"
x2="-508.6628"
y2="1525.4059"
>
<stop offset="1.970443e-002" stopColor="#FFC200" />
<stop offset="0.3866" stopColor="#FF31BD" />
<stop offset="0.6245" stopColor="#8E31BD" />
<stop offset="0.7758" stopColor="#6E8EDE" />
<stop offset="1" stopColor="#57EABA" />
</linearGradient>
<circle
id="XMLID_508_"
fill="none"
stroke="url(XMLID_420_)"
strokeWidth="2.4678"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
cx="-513.9"
cy="1525.4"
r="4"
/>
<path
id="XMLID_507_"
fill="#FF7B5B"
d="M-521,1518.3c-1.8,1.8-2.9,4.3-2.9,7.1c0,2.6,1,4.9,2.5,6.7L-521,1518.3z"
/>
<path
id="XMLID_506_"
fill="#FF7B5B"
d="M-506.9,1532.1c1.8-1.8,2.9-4.3,2.9-7.1c0-2.6-1-4.9-2.5-6.7L-506.9,1532.1z"
/>
</g>
<rect id="XMLID_125_" x="0" y="0" fill="none" width="36" height="36" stroke="none" /> {/* }//fill="#FFFFFF" */}
<linearGradient
id="XMLID_421_"
gradientUnits="userSpaceOnUse"
x1="-1625.151"
y1="-2518.4661"
x2="-1596.6696"
y2="-2518.4661"
gradientTransform="matrix(-1 0 0 -1 -1589.489 -2500.4661)"
>
<stop offset="1.970443e-002" stopColor="#FFC200" />
<stop offset="0.4731" stopColor="#FF31BD" />
<stop offset="0.6947" stopColor="#8E31BD" />
<stop offset="1" stopColor="#57EABA" />
</linearGradient>
<path
id="XMLID_124_"
fill="none"
stroke="url(#XMLID_421_)"
strokeWidth="2.94"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
d="M21.4,5.2L21.4,5.2c7.1,0,12.8,5.7,12.8,12.8v0c0,7.1-5.7,12.8-12.8,12.8H8.7V18C8.7,10.9,14.4,5.2,21.4,5.2z"
/>
<linearGradient id="XMLID_422_" gradientUnits="userSpaceOnUse" x1="18.041" y1="32.147" x2="38.7776" y2="-0.9289">
<stop offset="1.970443e-002" stopColor="#FFC200" />
<stop offset="0.4731" stopColor="#FF31BD" />
<stop offset="0.6947" stopColor="#8E31BD" />
<stop offset="1" stopColor="#57EABA" />
</linearGradient>
<rect id="XMLID_123_" x="26.9" y="13.8" fill="url(#XMLID_422_)" stroke="none" width="2.8" height="3.8" />
<linearGradient
id="XMLID_423_"
gradientUnits="userSpaceOnUse"
x1="13.0856"
y1="29.0402"
x2="33.8223"
y2="-4.0356"
>
<stop offset="1.970443e-002" stopColor="#FFC200" />
<stop offset="0.4731" stopColor="#FF31BD" />
<stop offset="0.6947" stopColor="#8E31BD" />
<stop offset="1" stopColor="#57EABA" />
</linearGradient>
<rect id="XMLID_122_" x="20" y="13.8" fill="url(#XMLID_422_)" stroke="none" width="2.8" height="3.8" />
<linearGradient id="XMLID_424_" gradientUnits="userSpaceOnUse" x1="0.338" y1="17.7555" x2="17.2654" y2="17.7555">
<stop offset="1.970443e-002" stopColor="#FFC200" />
<stop offset="0.4731" stopColor="#FF31BD" />
<stop offset="0.6947" stopColor="#8E31BD" />
<stop offset="1" stopColor="#57EABA" />
</linearGradient>
<circle
id="XMLID_121_"
fill="none"
stroke="#6E8EDE"
strokeWidth="2.94"
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit="10"
cx="8.8"
cy="17.8"
r="6"
/>
</svg>
),
[ICONS.LIVESTREAM_SOLID]: (props: CustomProps) => (
<svg
id="prefix__Layer_1"
xmlns="http://www.w3.org/2000/svg"
x={0}
y={0}
fill="none"
width={props.size || '18'}
height={props.size || '16'}
viewBox="0 0 36 36"
xmlSpace="preserve"
{...props}
>
<style>{'.prefix__st1{fill:#ff7b5b}.prefix__st3{fill:#79d1b6}'}</style>
<g id="prefix__XMLID_505_">
<linearGradient
id="prefix__XMLID_410_"
gradientUnits="userSpaceOnUse"
x1={-571.815}
y1={1525.406}
x2={-561.413}
y2={1525.406}
>
<stop offset={0.02} stopColor="#ffc200" />
<stop offset={0.387} stopColor="#ff31bd" />
<stop offset={0.625} stopColor="#8e31bd" />
<stop offset={0.776} stopColor="#6e8ede" />
<stop offset={1} stopColor="#57eaba" />
</linearGradient>
<circle
id="prefix__XMLID_508_"
cx={-566.6}
cy={1525.4}
r={4}
fill="none"
stroke="url(#prefix__XMLID_410_)"
strokeWidth={2.468}
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
/>
<path
id="prefix__XMLID_507_"
className="prefix__st1"
d="M-573.7 1518.3c-1.8 1.8-2.9 4.3-2.9 7.1 0 2.6 1 4.9 2.5 6.7l.4-13.8z"
/>
<path
id="prefix__XMLID_506_"
className="prefix__st1"
d="M-559.6 1532.1c1.8-1.8 2.9-4.3 2.9-7.1 0-2.6-1-4.9-2.5-6.7l-.4 13.8z"
/>
</g>
<path
id="prefix__XMLID_20_"
d="M21.4 5.2h0c7.1 0 12.8 5.7 12.8 12.8v0c0 7.1-5.7 12.8-12.8 12.8H8.7V18c0-7.1 5.7-12.8 12.7-12.8z"
fill="none"
stroke="#e729e1"
strokeWidth={2.94}
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
/>
<path id="prefix__XMLID_19_" className="prefix__st3" stroke="none" d="M26.9 13.8h2.8v3.8h-2.8z" />
<path id="prefix__XMLID_18_" className="prefix__st3" stroke="none" d="M20 13.8h2.8v3.8H20z" />
<circle
id="prefix__XMLID_17_"
cx={8.8}
cy={17.8}
r={6}
fill="none"
stroke="#ffa100"
strokeWidth={2.94}
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
/>
</svg>
),
[ICONS.LIVESTREAM_MONOCHROME]: (props: CustomProps) => (
<svg
id="prefix__Layer_1"
xmlns="http://www.w3.org/2000/svg"
x={0}
y={0}
fill="currentColor"
stroke="currentColor"
width={props.size || '18'}
height={props.size || '16'}
viewBox="0 0 36 36"
xmlSpace="preserve"
{...props}
>
<g id="prefix__XMLID_505_">
<linearGradient
id="prefix__XMLID_410_"
gradientUnits="userSpaceOnUse"
x1={-571.815}
y1={1525.406}
x2={-561.413}
y2={1525.406}
>
<stop offset={0.02} stopColor="#ffc200" />
<stop offset={0.387} stopColor="#ff31bd" />
<stop offset={0.625} stopColor="#8e31bd" />
<stop offset={0.776} stopColor="#6e8ede" />
<stop offset={1} stopColor="#57eaba" />
</linearGradient>
<circle
id="prefix__XMLID_508_"
cx={-566.6}
cy={1525.4}
r={4}
fill="none"
strokeWidth={2.468}
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
/>
<path id="prefix__XMLID_507_" d="M-573.7 1518.3c-1.8 1.8-2.9 4.3-2.9 7.1 0 2.6 1 4.9 2.5 6.7l.4-13.8z" />
<path id="prefix__XMLID_506_" d="M-559.6 1532.1c1.8-1.8 2.9-4.3 2.9-7.1 0-2.6-1-4.9-2.5-6.7l-.4 13.8z" />
</g>
<path
id="prefix__XMLID_20_"
d="M21.4 5.2h0c7.1 0 12.8 5.7 12.8 12.8v0c0 7.1-5.7 12.8-12.8 12.8H8.7V18c0-7.1 5.7-12.8 12.7-12.8z"
fill="none"
strokeWidth={2.94}
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
/>
<path id="prefix__XMLID_19_" d="M26.9 13.8h2.8v3.8h-2.8z" />
<path id="prefix__XMLID_18_" d="M20 13.8h2.8v3.8H20z" />
<circle
id="prefix__XMLID_17_"
cx={8.8}
cy={17.8}
r={6}
fill="none"
strokeWidth={2.94}
strokeLinecap="round"
strokeLinejoin="round"
strokeMiterlimit={10}
/>
</svg>
),
[ICONS.LIVESTREAM]: (props: CustomProps) => (
<svg
version="1.1"

View file

@ -1,9 +1,10 @@
import { connect } from 'react-redux';
import { makeSelectFilePartlyDownloaded, makeSelectClaimIsMine } from 'lbry-redux';
import { makeSelectFilePartlyDownloaded, makeSelectClaimIsMine, makeSelectClaimForUri } from 'lbry-redux';
import { makeSelectIsSubscribed } from 'redux/selectors/subscriptions';
import FileProperties from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
downloaded: makeSelectFilePartlyDownloaded(props.uri)(state),
isSubscribed: makeSelectIsSubscribed(props.uri)(state),
claimIsMine: makeSelectClaimIsMine(props.uri)(state),

View file

@ -1,4 +1,5 @@
// @flow
import type { Node } from 'react';
import * as ICONS from 'constants/icons';
import * as React from 'react';
import classnames from 'classnames';
@ -13,22 +14,31 @@ type Props = {
claimIsMine: boolean,
isSubscribed: boolean,
small: boolean,
claim: Claim,
properties?: (Claim) => ?Node,
};
export default function FileProperties(props: Props) {
const { uri, downloaded, claimIsMine, isSubscribed, small = false } = props;
const { uri, downloaded, claimIsMine, isSubscribed, small = false, properties, claim } = props;
return (
<div
className={classnames('file-properties', {
'file-properties--small': small,
})}
>
<VideoDuration uri={uri} />
<FileType uri={uri} />
{isSubscribed && <Icon tooltip icon={ICONS.SUBSCRIBE} />}
{!claimIsMine && downloaded && <Icon tooltip icon={ICONS.LIBRARY} />}
{typeof properties === 'function' ? (
properties(claim)
) : (
<>
<VideoDuration uri={uri} />
<FileType uri={uri} />
{isSubscribed && <Icon tooltip icon={ICONS.SUBSCRIBE} />}
{!claimIsMine && downloaded && <Icon tooltip icon={ICONS.LIBRARY} />}
<FilePrice hideFree uri={uri} />
<FilePrice hideFree uri={uri} />
</>
)}
</div>
);
}

View file

@ -0,0 +1,10 @@
import { connect } from 'react-redux';
import { makeSelectStakedLevelForChannelUri, makeSelectClaimForUri } from 'lbry-redux';
import LivestreamComment from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
stakedLevel: makeSelectStakedLevelForChannelUri(props.authorUri)(state),
});
export default connect(select)(LivestreamComment);

View file

@ -0,0 +1,78 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import MarkdownPreview from 'component/common/markdown-preview';
import ChannelThumbnail from 'component/channelThumbnail';
import { Menu, MenuButton } from '@reach/menu-button';
import Icon from 'component/common/icon';
import classnames from 'classnames';
import CommentMenuList from 'component/commentMenuList';
import UriIndicator from 'component/uriIndicator';
import CreditAmount from 'component/common/credit-amount';
type Props = {
uri: string,
claim: StreamClaim,
authorUri: string,
commentId: string,
message: string,
commentIsMine: boolean,
stakedLevel: number,
supportAmount: number,
};
function Comment(props: Props) {
const { claim, uri, authorUri, message, commentIsMine, commentId, stakedLevel, supportAmount } = props;
const [mouseIsHovering, setMouseHover] = React.useState(false);
const commentByOwnerOfContent = claim && claim.signing_channel && claim.signing_channel.permanent_url === authorUri;
return (
<li
className={classnames('livestream-comment', {
'livestream-comment--superchat': supportAmount > 0,
})}
onMouseOver={() => setMouseHover(true)}
onMouseOut={() => setMouseHover(false)}
>
{supportAmount > 0 && (
<div className="super-chat livestream-superchat__banner">
<div className="livestream-superchat__banner-corner" />
<CreditAmount amount={supportAmount} superChat className="livestream-superchat__amount" />
</div>
)}
<div className="livestream-comment__body">
{supportAmount > 0 && <ChannelThumbnail uri={authorUri} xsmall />}
<div className="livestream-comment__info">
<UriIndicator
className={classnames('comment__author', {
'comment__author--creator': commentByOwnerOfContent,
})}
link
external
uri={authorUri}
/>
<div className="livestream-comment__text">
<MarkdownPreview content={message} promptLinks stakedLevel={stakedLevel} />
</div>
</div>
</div>
<div className="livestream-comment__menu">
<Menu>
<MenuButton className="menu__button">
<Icon
size={18}
className={mouseIsHovering ? 'comment__menu-icon--hovering' : 'comment__menu-icon'}
icon={ICONS.MORE_VERTICAL}
/>
</MenuButton>
<CommentMenuList uri={uri} commentId={commentId} authorUri={authorUri} commentIsMine={commentIsMine} />
</Menu>
</div>
</li>
);
}
export default Comment;

View file

@ -1,14 +1,26 @@
import { connect } from 'react-redux';
import { makeSelectClaimForUri } from 'lbry-redux';
import { doCommentSocketConnect, doCommentSocketDisconnect } from 'redux/actions/websocket';
import { doCommentList } from 'redux/actions/comments';
import { makeSelectTopLevelCommentsForUri, selectIsFetchingComments } from 'redux/selectors/comments';
import { doCommentList, doSuperChatList } from 'redux/actions/comments';
import {
makeSelectTopLevelCommentsForUri,
selectIsFetchingComments,
makeSelectSuperChatsForUri,
makeSelectSuperChatTotalAmountForUri,
} from 'redux/selectors/comments';
import LivestreamFeed from './view';
const select = (state, props) => ({
claim: makeSelectClaimForUri(props.uri)(state),
comments: makeSelectTopLevelCommentsForUri(props.uri)(state).slice(0, 75),
fetchingComments: selectIsFetchingComments(state),
superChats: makeSelectSuperChatsForUri(props.uri)(state),
superChatsTotalAmount: makeSelectSuperChatTotalAmountForUri(props.uri)(state),
});
export default connect(select, { doCommentSocketConnect, doCommentSocketDisconnect, doCommentList })(LivestreamFeed);
export default connect(select, {
doCommentSocketConnect,
doCommentSocketDisconnect,
doCommentList,
doSuperChatList,
})(LivestreamFeed);

View file

@ -1,10 +1,14 @@
// @flow
import React from 'react';
import classnames from 'classnames';
import Card from 'component/common/card';
import Spinner from 'component/spinner';
import CommentCreate from 'component/commentCreate';
import CommentView from 'component/comment';
import LivestreamComment from 'component/livestreamComment';
import Button from 'component/button';
import UriIndicator from 'component/uriIndicator';
import CreditAmount from 'component/common/credit-amount';
import ChannelThumbnail from 'component/channelThumbnail';
import Tooltip from 'component/common/tooltip';
type Props = {
uri: string,
@ -16,9 +20,15 @@ type Props = {
doCommentList: (string) => void,
comments: Array<Comment>,
fetchingComments: boolean,
doSuperChatList: (string) => void,
superChats: Array<Comment>,
superChatsTotalAmount: number,
};
export default function LivestreamFeed(props: Props) {
const VIEW_MODE_CHAT = 'view_chat';
const VIEW_MODE_SUPER_CHAT = 'view_superchat';
export default function LivestreamComments(props: Props) {
const {
claim,
uri,
@ -28,16 +38,22 @@ export default function LivestreamFeed(props: Props) {
comments,
doCommentList,
fetchingComments,
doSuperChatList,
superChats,
superChatsTotalAmount,
} = props;
const commentsRef = React.createRef();
const hasScrolledComments = React.useRef();
const [viewMode, setViewMode] = React.useState(VIEW_MODE_CHAT);
const [performedInitialScroll, setPerformedInitialScroll] = React.useState(false);
const claimId = claim && claim.claim_id;
const commentsLength = comments && comments.length;
const commentsToDisplay = viewMode === VIEW_MODE_CHAT ? comments : superChats;
React.useEffect(() => {
if (claimId) {
doCommentList(uri);
doSuperChatList(uri);
doCommentSocketConnect(uri, claimId);
}
@ -46,7 +62,7 @@ export default function LivestreamFeed(props: Props) {
doCommentSocketDisconnect(claimId);
}
};
}, [claimId, uri, doCommentList, doCommentSocketConnect, doCommentSocketDisconnect]);
}, [claimId, uri, doCommentList, doSuperChatList, doCommentSocketConnect, doCommentSocketDisconnect]);
React.useEffect(() => {
const element = commentsRef.current;
@ -92,51 +108,87 @@ export default function LivestreamFeed(props: Props) {
}
return (
<Card
title={__('Live discussion')}
smallTitle
className="livestream__discussion"
actions={
<>
{fetchingComments && (
<div className="main--empty">
<Spinner />
</div>
)}
<div
ref={commentsRef}
className={classnames('livestream__comments-wrapper', {
'livestream__comments-wrapper--with-height': commentsLength > 0,
})}
>
{!fetchingComments && comments.length > 0 ? (
<div className="livestream__comments">
{comments.map((comment) => (
<div key={comment.comment_id} className={classnames('livestream__comment')}>
<CommentView
livestream
isTopLevel
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}
/>
</div>
<div className="card livestream__discussion">
<div className="card__header--between livestream-discussion__header">
<div className="livestream-discussion__title">{__('Live discussion')}</div>
{superChatsTotalAmount > 0 && (
<div className="recommended-content__toggles">
<Button
className={classnames('button-toggle', {
'button-toggle--active': viewMode === VIEW_MODE_CHAT,
})}
label={__('Chat')}
onClick={() => setViewMode(VIEW_MODE_CHAT)}
/>
<Button
className={classnames('button-toggle', {
'button-toggle--active': viewMode === VIEW_MODE_SUPER_CHAT,
})}
label={
<>
<CreditAmount amount={superChatsTotalAmount} size={8} /> {__('Tipped')}
</>
}
onClick={() => setViewMode(VIEW_MODE_SUPER_CHAT)}
/>
</div>
)}
</div>
<>
{fetchingComments && !comments && (
<div className="main--empty">
<Spinner />
</div>
)}
<div ref={commentsRef} className="livestream__comments-wrapper">
{viewMode === VIEW_MODE_CHAT && superChatsTotalAmount > 0 && (
<div className="livestream-superchats__wrapper">
<div className="livestream-superchats__inner">
{superChats.map((superChat: Comment) => (
<Tooltip key={superChat.comment_id} label={superChat.comment}>
<div className="livestream-superchat">
<div className="livestream-superchat__thumbnail">
<ChannelThumbnail uri={superChat.channel_url} xsmall />
</div>
<div className="livestream-superchat__info">
<UriIndicator uri={superChat.channel_url} link />
<CreditAmount
size={10}
className="livestream-superchat__amount-large"
amount={superChat.support_amount}
/>
</div>
</div>
</Tooltip>
))}
</div>
) : (
<div className="main--empty" />
)}
</div>
</div>
)}
{!fetchingComments && comments.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}
/>
))}
</div>
) : (
<div className="main--empty" style={{ flex: 1 }} />
)}
<div className="livestream__comment-create">
<CommentCreate livestream bottom embed={embed} uri={uri} />
</div>
</>
}
/>
</div>
</>
</div>
);
}

View file

@ -3,6 +3,7 @@ import { BITWAVE_EMBED_URL } from 'constants/livestream';
import React from 'react';
import FileTitleSection from 'component/fileTitleSection';
import LivestreamComments from 'component/livestreamComments';
import { useIsMobile } from 'effects/use-screensize';
type Props = {
uri: string,
@ -13,6 +14,7 @@ type Props = {
export default function LivestreamLayout(props: Props) {
const { claim, uri, isLive, activeViewers } = props;
const isMobile = useIsMobile();
if (!claim || !claim.signing_channel) {
return null;
@ -41,9 +43,11 @@ export default function LivestreamLayout(props: Props) {
})}
</div>
)}
{isMobile && <LivestreamComments uri={uri} />}
<FileTitleSection uri={uri} livestream isLive={isLive} activeViewers={activeViewers} />
</div>
<LivestreamComments uri={uri} />
</>
);
}

View file

@ -4,6 +4,8 @@ import React from 'react';
import Card from 'component/common/card';
import ClaimPreview from 'component/claimPreview';
import { Lbry } from 'lbry-redux';
import { useHistory } from 'react-router';
import { formatLbryUrlForWeb } from 'util/url';
type Props = {
channelClaim: ChannelClaim,
@ -11,6 +13,7 @@ type Props = {
export default function LivestreamLink(props: Props) {
const { channelClaim } = props;
const { push } = useHistory();
const [livestreamClaim, setLivestreamClaim] = React.useState(false);
const [isLivestreaming, setIsLivestreaming] = React.useState(false);
const livestreamChannelId = channelClaim.claim_id || ''; // TODO: fail in a safer way, probably
@ -66,7 +69,14 @@ export default function LivestreamLink(props: Props) {
// gonna pass the wrapper in so I don't have to rewrite the dmca/blocking logic in claimPreview.
const element = (props: { children: any }) => (
<Card className="livestream__channel-link" title={__('Live stream in progress')}>
<Card
role="button"
className="livestream__channel-link"
title={__('Live stream in progress')}
onClick={() => {
push(formatLbryUrlForWeb(livestreamClaim.canonical_url));
}}
>
{props.children}
</Card>
);

View file

@ -0,0 +1,6 @@
import { connect } from 'react-redux';
import LivestreamCurrent from './view';
const select = (state) => ({});
export default connect(select)(LivestreamCurrent);

View file

@ -0,0 +1,75 @@
// @flow
import * as ICONS from 'constants/icons';
import { BITWAVE_LIVE_API } from 'constants/livestream';
import React from 'react';
import Icon from 'component/common/icon';
import Spinner from 'component/spinner';
import ClaimTilesDiscover from 'component/claimTilesDiscover';
const LIVESTREAM_POLL_IN_MS = 10 * 1000;
export default function LivestreamList() {
const [loading, setLoading] = React.useState(true);
const [livestreamMap, setLivestreamMap] = React.useState();
React.useEffect(() => {
function checkCurrentLivestreams() {
fetch(BITWAVE_LIVE_API)
.then((res) => res.json())
.then((res) => {
setLoading(false);
if (!res.data) {
setLivestreamMap({});
return;
}
const livestreamMap = res.data.reduce((acc, curr) => {
return {
...acc,
[curr.claimId]: curr,
};
}, {});
setLivestreamMap(livestreamMap);
})
.catch((err) => {
setLoading(false);
});
}
checkCurrentLivestreams();
let fetchInterval = setInterval(checkCurrentLivestreams, LIVESTREAM_POLL_IN_MS);
return () => {
if (fetchInterval) {
clearInterval(fetchInterval);
}
};
}, []);
return (
<>
{loading && (
<div className="main--empty">
<Spinner delayed />
</div>
)}
{livestreamMap && Object.keys(livestreamMap).length > 0 && (
<ClaimTilesDiscover
hasNoSource
channelIds={Object.keys(livestreamMap)}
limitClaimsPerChannel={1}
renderProperties={(claim) => {
const livestream = livestreamMap[claim.signing_channel.claim_id];
return (
<span className="livestream__viewer-count">
{livestream.viewCount} <Icon icon={ICONS.EYE} />
</span>
);
}}
/>
)}
</>
);
}

View file

@ -29,6 +29,7 @@ type Props = {
videoTheaterMode: boolean,
isMarkdown?: boolean,
livestream?: boolean,
rightSide?: Node,
backout: {
backLabel?: string,
backNavDefault?: string,
@ -51,6 +52,7 @@ function Page(props: Props) {
videoTheaterMode,
isMarkdown = false,
livestream,
rightSide,
} = props;
const {
@ -114,6 +116,8 @@ function Page(props: Props) {
})}
>
{children}
{!isMobile && rightSide && <div className="main__right-side">{rightSide}</div>}
</main>
{/* @if TARGET='app' */}
<StatusBar />

View file

@ -613,7 +613,7 @@ function PublishForm(props: Props) {
</div>
)}
<section>
<div className="card__actions">
<div className="section__actions">
<Button
button="primary"
onClick={handlePublish}

View file

@ -38,6 +38,7 @@ import PasswordSetPage from 'page/passwordSet';
import SignInVerifyPage from 'page/signInVerify';
import ChannelsPage from 'page/channels';
import LiveStreamSetupPage from 'page/livestreamSetup';
import LivestreamCurrentPage from 'page/livestreamCurrent';
import EmbedWrapperPage from 'page/embedWrapper';
import TopPage from 'page/top';
import Welcome from 'page/welcome';
@ -285,6 +286,7 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.WALLET}`} exact component={WalletPage} />
<PrivateRoute {...props} path={`/$/${PAGES.CHANNELS}`} component={ChannelsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LIVESTREAM}`} component={LiveStreamSetupPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LIVESTREAM_CURRENT}`} component={LivestreamCurrentPage} />
<PrivateRoute {...props} path={`/$/${PAGES.BUY}`} component={BuyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.RECEIVE}`} component={ReceivePage} />
<PrivateRoute {...props} path={`/$/${PAGES.SEND}`} component={SendPage} />

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { selectBalance } from 'lbry-redux';
import WalletTipAmountSelector from './view';
const select = (state, props) => ({
balance: selectBalance(state),
});
export default connect(select)(WalletTipAmountSelector);

View file

@ -0,0 +1,123 @@
// @flow
import * as ICONS from 'constants/icons';
import * as PAGES from 'constants/pages';
import React from 'react';
import Button from 'component/button';
import { FormField } from 'component/common/form';
import { MINIMUM_PUBLISH_BID } from 'constants/claim';
import CreditAmount from 'component/common/credit-amount';
import I18nMessage from 'component/i18nMessage';
import classnames from 'classnames';
import usePersistedState from 'effects/use-persisted-state';
import WalletSpendableBalanceHelp from 'component/walletSpendableBalanceHelp';
const DEFAULT_TIP_AMOUNTS = [1, 5, 25, 100];
type Props = {
balance: number,
amount: number,
onChange: (number) => void,
};
function WalletTipAmountSelector(props: Props) {
const { balance, amount, onChange } = props;
const [useCustomTip, setUseCustomTip] = usePersistedState('comment-support:useCustomTip', false);
const [tipError, setTipError] = React.useState();
React.useEffect(() => {
const regexp = RegExp(/^(\d*([.]\d{0,8})?)$/);
const validTipInput = regexp.test(String(amount));
let tipError;
if (!amount) {
tipError = __('Amount must be a number');
} else if (amount <= 0) {
tipError = __('Amount must be a positive number');
} else if (amount < MINIMUM_PUBLISH_BID) {
tipError = __('Amount must be higher');
} else if (!validTipInput) {
tipError = __('Amount must have no more than 8 decimal places');
} else if (amount === balance) {
tipError = __('Please decrease the amount to account for transaction fees');
} else if (amount > balance) {
tipError = __('Not enough Credits');
}
setTipError(tipError);
}, [amount, balance, setTipError]);
function handleCustomPriceChange(amount: number) {
const tipAmount = parseFloat(amount);
onChange(tipAmount);
}
return (
<>
<div className="section">
{DEFAULT_TIP_AMOUNTS.map((defaultAmount) => (
<Button
key={defaultAmount}
disabled={amount > balance}
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': defaultAmount === amount,
'button-toggle--disabled': amount > balance,
})}
label={defaultAmount}
icon={ICONS.LBC}
onClick={() => {
handleCustomPriceChange(defaultAmount);
setUseCustomTip(false);
}}
/>
))}
<Button
button="alt"
className={classnames('button-toggle button-toggle--expandformobile', {
'button-toggle--active': !DEFAULT_TIP_AMOUNTS.includes(amount),
})}
icon={ICONS.LBC}
label={__('Custom')}
onClick={() => setUseCustomTip(true)}
/>
{DEFAULT_TIP_AMOUNTS.some((val) => val > balance) && (
<Button
button="secondary"
className="button-toggle-group-action"
icon={ICONS.BUY}
title={__('Buy more LBRY Credits')}
navigate={`/$/${PAGES.BUY}`}
/>
)}
</div>
{useCustomTip && (
<div className="comment__tip-input">
<FormField
autoFocus
name="tip-input"
label={
<React.Fragment>
{__('Custom support amount')}{' '}
<I18nMessage tokens={{ lbc_balance: <CreditAmount precision={4} amount={balance} /> }}>
(%lbc_balance% available)
</I18nMessage>
</React.Fragment>
}
className="form-field--price-amount"
error={tipError}
min="0"
step="any"
type="number"
placeholder="1.23"
value={amount}
onChange={(event) => handleCustomPriceChange(event.target.value)}
/>
</div>
)}
{!useCustomTip && <WalletSpendableBalanceHelp />}
</>
);
}
export default WalletTipAmountSelector;

View file

@ -278,6 +278,9 @@ export const COMMENT_MODERATION_UN_BLOCK_STARTED = 'COMMENT_MODERATION_UN_BLOCK_
export const COMMENT_MODERATION_UN_BLOCK_COMPLETE = 'COMMENT_MODERATION_UN_BLOCK_COMPLETE';
export const COMMENT_MODERATION_UN_BLOCK_FAILED = 'COMMENT_MODERATION_UN_BLOCK_FAILED';
export const COMMENT_RECEIVED = 'COMMENT_RECEIVED';
export const COMMENT_SUPER_CHAT_LIST_STARTED = 'COMMENT_SUPER_CHAT_LIST_STARTED';
export const COMMENT_SUPER_CHAT_LIST_COMPLETED = 'COMMENT_SUPER_CHAT_LIST_COMPLETED';
export const COMMENT_SUPER_CHAT_LIST_FAILED = 'COMMENT_SUPER_CHAT_LIST_FAILED';
// Blocked channels
export const TOGGLE_BLOCK_CHANNEL = 'TOGGLE_BLOCK_CHANNEL';

View file

@ -1,5 +1,6 @@
export const FF_MAX_CHARS_DEFAULT = 2000;
export const FF_MAX_CHARS_IN_COMMENT = 2000;
export const FF_MAX_CHARS_IN_LIVESTREAM_COMMENT = 500;
export const FF_MAX_CHARS_IN_DESCRIPTION = 5000;
export const FF_MAX_CHARS_REPORT_CONTENT_DETAILS = 500;
export const FF_MAX_CHARS_REPORT_CONTENT_ADDRESS = 255;

View file

@ -65,3 +65,4 @@ exports.CHANNEL_NEW = 'channel/new';
exports.NOTIFICATIONS = 'notifications';
exports.YOUTUBE_SYNC = 'youtube';
exports.LIVESTREAM = 'livestream';
exports.LIVESTREAM_CURRENT = 'live';

View file

@ -3,6 +3,7 @@ import { BITWAVE_LIVE_API } from 'constants/livestream';
import React from 'react';
import Page from 'component/page';
import LivestreamLayout from 'component/livestreamLayout';
import LivestreamComments from 'component/livestreamComments';
import analytics from 'analytics';
import { Lbry } from 'lbry-redux';
@ -111,7 +112,7 @@ export default function LivestreamPage(props: Props) {
}, [doSetPlayingUri]);
return (
<Page className="file-page" filePage livestream>
<Page className="file-page" noFooter livestream rightSide={<LivestreamComments uri={uri} />}>
<LivestreamLayout uri={uri} activeViewers={activeViewers} isLive={isLive} />
</Page>
);

View file

@ -0,0 +1,9 @@
import { connect } from 'react-redux';
import { selectUser } from 'redux/selectors/user';
import LivestreamCurrent from './view';
const select = (state) => ({
user: selectUser(state),
});
export default connect(select)(LivestreamCurrent);

View file

@ -0,0 +1,34 @@
// @flow
import React from 'react';
import LivestreamList from 'component/livestreamList';
import Button from 'component/button';
import Page from 'component/page';
import Yrbl from 'component/yrbl';
type Props = {
user: ?User,
};
export default function LivestreamCurrentPage(props: Props) {
const { user } = props;
const canView = user && user.global_mod;
return (
<Page>
{canView ? (
<LivestreamList />
) : (
<Yrbl
type="sad"
title={__("This page isn't quite ready")}
subtitle={__('Check back later.')}
actions={
<div className="section__actions">
<Button button="primary" navigate="/" label={__('Go Home')} />
</div>
}
/>
)}
</Page>
);
}

View file

@ -60,6 +60,44 @@ export function doCommentList(uri: string, page: number = 1, pageSize: number =
};
}
export function doSuperChatList(uri: string) {
return (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const claim = selectClaimsByUri(state)[uri];
const claimId = claim ? claim.claim_id : null;
if (!claimId) {
console.error('No claimId found for uri: ', uri); //eslint-disable-line
return;
}
dispatch({
type: ACTIONS.COMMENT_SUPER_CHAT_LIST_STARTED,
});
return Comments.super_list({
claim_id: claimId,
})
.then((result: CommentListResponse) => {
const { items: comments, total_amount: totalAmount } = result;
dispatch({
type: ACTIONS.COMMENT_SUPER_CHAT_LIST_COMPLETED,
data: {
comments,
totalAmount,
uri: uri,
},
});
})
.catch((error) => {
dispatch({
type: ACTIONS.COMMENT_SUPER_CHAT_LIST_FAILED,
data: error,
});
});
};
}
export function doCommentReactList(uri: string | null, commentId?: string) {
return (dispatch: Dispatch, getState: GetState) => {
const state = getState();
@ -201,9 +239,10 @@ export function doCommentCreate(
claim_id: string = '',
parent_id?: string,
uri: string,
livestream?: boolean = false
livestream?: boolean = false,
txid?: string
) {
return (dispatch: Dispatch, getState: GetState) => {
return async (dispatch: Dispatch, getState: GetState) => {
const state = getState();
const activeChannelClaim = selectActiveChannelClaim(state);
@ -216,6 +255,16 @@ export function doCommentCreate(
type: ACTIONS.COMMENT_CREATE_STARTED,
});
let signatureData;
if (activeChannelClaim) {
try {
signatureData = await Lbry.channel_sign({
channel_id: activeChannelClaim.claim_id,
hexdata: toHex(comment),
});
} catch (e) {}
}
if (parent_id) {
const notification = makeSelectNotificationForCommentId(parent_id)(state);
if (notification && !notification.is_seen) {
@ -223,11 +272,19 @@ export function doCommentCreate(
}
}
return Lbry.comment_create({
if (!signatureData) {
return dispatch(doToast({ isError: true, message: __('Unable to verify your channel. Please try again.') }));
}
return Comments.comment_create({
comment: comment,
claim_id: claim_id,
channel_id: activeChannelClaim.claim_id,
channel_name: activeChannelClaim.name,
parent_id: parent_id,
signature: signatureData.signature,
signing_ts: signatureData.signing_ts,
...(txid ? { support_tx_id: txid } : {}),
})
.then((result: CommentCreateResponse) => {
dispatch({
@ -258,6 +315,8 @@ export function doCommentCreate(
isError: true,
})
);
return Promise.reject(error);
});
};
}

View file

@ -11,6 +11,7 @@ const defaultState: CommentsState = {
// Remove commentsByUri
// It is not needed and doesn't provide anything but confusion
commentsByUri: {}, // URI -> claimId
superChatsByUri: {},
isLoading: false,
isCommenting: false,
myComments: undefined,
@ -213,6 +214,28 @@ export default handleActions(
};
},
[ACTIONS.COMMENT_SUPER_CHAT_LIST_FAILED]: (state: CommentsState, action: any) => ({
...state,
isLoading: false,
}),
[ACTIONS.COMMENT_SUPER_CHAT_LIST_STARTED]: (state) => ({ ...state, isLoading: true }),
[ACTIONS.COMMENT_SUPER_CHAT_LIST_COMPLETED]: (state: CommentsState, action: any) => {
const { comments, totalAmount, uri } = action.data;
return {
...state,
superChatsByUri: {
...state.superChatsByUri,
[uri]: {
comments,
totalAmount,
},
},
isLoading: false,
};
},
[ACTIONS.COMMENT_LIST_FAILED]: (state: CommentsState, action: any) => ({
...state,
isLoading: false,
@ -224,6 +247,7 @@ export default handleActions(
const commentsByClaimId = Object.assign({}, state.byId);
const allCommentsById = Object.assign({}, state.commentById);
const topLevelCommentsById = Object.assign({}, state.topLevelCommentsById);
const superChatsByUri = Object.assign({}, state.superChatsByUri);
const commentsForId = topLevelCommentsById[claimId];
allCommentsById[comment.comment_id] = comment;
@ -244,12 +268,47 @@ export default handleActions(
// We don't care to keep existing lower level comments since this is just for livestreams
commentsByClaimId[claimId] = topLevelCommentsById[claimId];
if (comment.support_amount > 0) {
const superChatForUri = superChatsByUri[uri];
const superChatCommentsForUri = superChatForUri && superChatForUri.comments;
let sortedSuperChatComments = [];
let hasAddedNewComment = false;
if (superChatCommentsForUri && superChatCommentsForUri.length > 0) {
// Go for the entire length of superChatCommentsForUri since a comment will be added to this list
for (var i = 0; i < superChatCommentsForUri.length; i++) {
const existingSuperChat = superChatCommentsForUri[i];
if (existingSuperChat.support_amount < comment.support_amount && !hasAddedNewComment) {
hasAddedNewComment = true;
sortedSuperChatComments.push(comment);
sortedSuperChatComments.push(existingSuperChat);
} else {
sortedSuperChatComments.push(existingSuperChat);
}
// If the new superchat hasn't been added yet, it must be the smallest superchat in the list
if (
i === superChatCommentsForUri.length - 1 &&
sortedSuperChatComments.length === superChatCommentsForUri.length
) {
sortedSuperChatComments.push(comment);
}
}
superChatsByUri[uri].comments = sortedSuperChatComments;
superChatsByUri[uri].totalAmount += 1;
} else {
superChatsByUri[uri] = { comments: [comment], totalAmount: comment.support_amount };
}
}
return {
...state,
byId: commentsByClaimId,
commentById: allCommentsById,
commentsByUri,
topLevelCommentsById,
superChatsByUri,
};
},

View file

@ -39,6 +39,8 @@ export const selectCommentsByClaimId = createSelector(selectState, selectComment
return comments;
});
export const selectSuperchatsByUri = createSelector(selectState, (state) => state.superChatsByUri);
export const selectTopLevelCommentsByClaimId = createSelector(selectState, selectCommentsById, (state, byId) => {
const byClaimId = state.topLevelCommentsById || {};
const comments = {};
@ -299,3 +301,26 @@ export const makeSelectUriIsBlockingOrUnBlocking = (uri: string) =>
createSelector(selectBlockingByUri, selectUnBlockingByUri, (blockingByUri, unBlockingByUri) => {
return blockingByUri[uri] || unBlockingByUri[uri];
});
export const makeSelectSuperChatDataForUri = (uri: string) =>
createSelector(selectSuperchatsByUri, (byUri) => {
return byUri[uri];
});
export const makeSelectSuperChatsForUri = (uri: string) =>
createSelector(makeSelectSuperChatDataForUri(uri), (superChatData) => {
if (!superChatData) {
return undefined;
}
return superChatData.comments;
});
export const makeSelectSuperChatTotalAmountForUri = (uri: string) =>
createSelector(makeSelectSuperChatDataForUri(uri), (superChatData) => {
if (!superChatData) {
return 0;
}
return superChatData.totalAmount;
});

View file

@ -51,6 +51,7 @@
@import 'component/spinner';
@import 'component/splash';
@import 'component/status-bar';
@import 'component/superchat';
@import 'component/syntax-highlighter';
@import 'component/table';
@import 'component/livestream';

View file

@ -232,7 +232,7 @@
}
.button--emoji {
font-size: 1.25rem;
font-size: 1.1rem;
border-radius: 3rem;
}
@ -282,10 +282,6 @@ svg + .button__label {
&:last-of-type {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
// since we're abusing "button-toggle" let it stand alone properly
&:not(:first-of-type) {
margin-right: var(--spacing-s);
}
}
}

View file

@ -17,7 +17,7 @@
.card--section {
position: relative;
padding: var(--spacing-l);
padding: var(--spacing-m);
}
.card--reward-total {
@ -82,23 +82,22 @@
.card__title-section {
@extend .section__flex;
padding: var(--spacing-m) var(--spacing-l);
@media (max-width: $breakpoint-small) {
padding: 0;
@media (min-width: $breakpoint-small) {
padding: var(--spacing-s) var(--spacing-m);
}
}
.card__title-section--body-list {
padding: var(--spacing-m);
padding-left: var(--spacing-s);
@media (max-width: $breakpoint-small) {
padding: 0;
@media (min-width: $breakpoint-small) {
padding: var(--spacing-m);
}
}
.card__title-section--small {
padding: var(--spacing-s) var(--spacing-m);
padding: var(--spacing-s);
}
.card__actions--inline {
@ -175,7 +174,7 @@
.card__title {
display: block;
align-items: center;
font-size: var(--font-title);
font-size: var(--font-large);
font-weight: var(--font-weight-light);
& > *:not(:last-child) {
@ -200,11 +199,9 @@
.card__title-actions {
align-self: flex-start;
padding: var(--spacing-m);
padding-right: var(--spacing-l);
@media (max-width: $breakpoint-small) {
padding: 0;
@media (min-width: $breakpoint-small) {
padding: var(--spacing-s);
}
}
@ -248,6 +245,13 @@
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
@media (max-width: $breakpoint-small) {
padding: var(--spacing-s);
padding-bottom: 0;
margin: 0;
margin-bottom: var(--spacing-s);
}
}
.card__header--nowrap {
@ -262,7 +266,7 @@
}
.card__body {
padding: var(--spacing-l);
padding: var(--spacing-m);
&:not(.card__body--no-title) {
padding-top: 0;
@ -279,10 +283,11 @@
}
.card__main-actions {
padding: var(--spacing-l);
padding: var(--spacing-m);
padding-bottom: 0;
margin-bottom: var(--spacing-l);
margin-bottom: var(--spacing-s);
border-top: 1px solid var(--color-border);
height: 100%;
&:only-child {
border-top: none;
@ -290,10 +295,10 @@
}
.card__body-actions {
padding: var(--spacing-l);
padding: var(--spacing-s);
@media (max-width: $breakpoint-small) {
padding: var(--spacing-s);
@media (min-width: $breakpoint-small) {
padding: var(--spacing-m);
}
}
@ -317,15 +322,12 @@
}
}
.card__header,
.card__body,
.card__main-actions {
@media (max-width: $breakpoint-small) {
padding: var(--spacing-s);
padding-bottom: 0;
margin: 0;
margin-bottom: var(--spacing-s);
}
padding: var(--spacing-m);
padding-bottom: 0;
margin: 0;
margin-bottom: var(--spacing-m);
}
.card__bottom-gutter {

View file

@ -77,6 +77,12 @@ $metadata-z-index: 1;
width: 3rem;
}
.channel-thumbnail--xsmall {
height: 2.1rem;
width: 2.1rem;
margin-right: var(--spacing-xs);
}
.chanel-thumbnail--waiting {
background-color: var(--color-gray-5);
border-radius: var(--border-radius);
@ -400,7 +406,7 @@ $metadata-z-index: 1;
}
.channel-staked__indicator {
margin-left: 2px;
margin-left: 1px;
z-index: 3;
fill: var(--color-gray-3);
}

View file

@ -356,7 +356,8 @@
.claim-grid__header {
margin-bottom: var(--spacing-m);
display: inline-block;
display: flex;
align-items: center;
.button {
&:hover {

View file

@ -90,13 +90,9 @@
.claim-search__menu-group {
display: flex;
flex-wrap: nowrap;
&:last-of-type {
.button-toggle:last-of-type {
margin-right: 0;
}
}
margin-right: var(--spacing-s);
}
.claim-search__menu-group--between {
display: flex;
flex-wrap: nowrap;

View file

@ -15,6 +15,7 @@ $thumbnailWidthSmall: 1rem;
.comment__sort {
margin: var(--spacing-s) 0;
margin-right: var(--spacing-s);
display: block;
@media (min-width: $breakpoint-small) {
@ -24,7 +25,6 @@ $thumbnailWidthSmall: 1rem;
}
.comment__create {
padding-bottom: var(--spacing-m);
font-size: var(--font-small);
}
@ -39,6 +39,7 @@ $thumbnailWidthSmall: 1rem;
flex-direction: column;
font-size: var(--font-small);
margin: 0;
position: relative;
&:not(:first-child) {
margin-top: var(--spacing-l);
@ -53,13 +54,11 @@ $thumbnailWidthSmall: 1rem;
}
.channel-staked__wrapper {
@media (max-width: $breakpoint-small) {
padding: 0;
left: 0;
bottom: -1rem;
padding: -1rem;
margin-left: 0;
}
padding: 0;
left: calc(#{$thumbnailWidthSmall} / 4);
bottom: -1rem;
padding: -1rem;
margin-left: 0;
}
}
}
@ -102,14 +101,28 @@ $thumbnailWidthSmall: 1rem;
}
}
.comment--livestream {
margin-right: 0;
}
.comment--slimed {
opacity: 0.6;
}
.comment__sc-preview {
display: flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: var(--spacing-s);
margin: var(--spacing-s) 0;
}
.comment__edit-input {
margin-top: var(--spacing-xxs);
}
.comment__scpreview-amount {
margin-right: var(--spacing-m);
font-size: var(--font-large);
}
.comment__threadline {
@extend .button--alt;
height: auto;
@ -155,12 +168,12 @@ $thumbnailWidthSmall: 1rem;
border-radius: 4px;
}
.comment__body_container {
.comment__body-container {
flex: 1;
margin-left: var(--spacing-xs);
@media (min-width: $breakpoint-small) {
margin-left: var(--spacing-m);
margin-left: var(--spacing-s);
}
}
@ -231,7 +244,7 @@ $thumbnailWidthSmall: 1rem;
opacity: 0.5;
white-space: nowrap;
height: 100%;
margin-left: var(--spacing-xs);
margin-right: var(--spacing-xs);
&:focus {
@include linkFocus;
@ -252,9 +265,7 @@ $thumbnailWidthSmall: 1rem;
}
.comment__char-count {
align-self: flex-end;
font-size: var(--font-xsmall);
padding-top: var(--spacing-xxs);
}
.comment__char-count-mde {
@ -407,3 +418,7 @@ $thumbnailWidthSmall: 1rem;
white-space: pre-line;
margin-right: var(--spacing-s);
}
.comment__tip-input {
margin: var(--spacing-s) 0;
}

View file

@ -5,11 +5,11 @@
.card + .file-render,
.card + .file-page__video-container,
.card + .content__cover {
margin-top: var(--spacing-l);
margin-top: var(--spacing-m);
}
.card + .file-render {
margin-top: var(--spacing-l);
margin-top: var(--spacing-m);
}
.file-page__md {

View file

@ -440,6 +440,21 @@ fieldset-group {
margin-top: 2.5%;
}
.form-field__textarea-info {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-top: var(--spacing-xxs);
margin-bottom: var(--spacing-s);
}
.form-field__quick-emojis {
> *:not(:last-child) {
margin-right: var(--spacing-s);
}
}
fieldset-section {
.form-field__internal-option {
margin-top: var(--spacing-s);

View file

@ -1,3 +1,5 @@
$discussion-header__height: 3rem;
.livestream {
flex: 1;
width: 100%;
@ -23,52 +25,125 @@
}
.livestream__discussion {
min-height: 0%;
width: 100%;
margin-top: var(--spacing-m);
margin-bottom: var(--spacing-s);
@media (min-width: $breakpoint-small) {
width: 35rem;
margin-left: var(--spacing-m);
margin-top: 0;
margin: 0;
width: var(--livestream-comments-width);
height: calc(100vh - var(--header-height));
position: fixed;
right: 0;
top: var(--header-height);
bottom: 0;
border-radius: 0;
border-top: none;
border-bottom: none;
border-right: none;
.card__main-actions {
padding: 0;
}
}
}
.livestream__comments-wrapper {
overflow-y: scroll;
.livestream-discussion__header {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-s);
margin-bottom: 0;
align-items: center;
@media (min-width: $breakpoint-small) {
height: $discussion-header__height;
padding: 0 var(--spacing-s);
padding-right: 0;
}
}
.livestream__comments-wrapper--with-height {
height: 40vh;
.livestream-discussion__title {
@extend .card__title-section;
@extend .card__title-section--small;
padding: 0;
}
.livestream__comments-wrapper {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height) - #{$discussion-header__height});
}
.livestream__comments {
display: flex;
flex-direction: column-reverse;
font-size: var(--font-small);
overflow-y: scroll;
overflow-x: visible;
padding-top: var(--spacing-s);
width: 100%;
}
.livestream__comment {
margin-top: var(--spacing-s);
display: flex;
flex-wrap: wrap;
.livestream-comment {
list-style-type: none;
position: relative;
.comment__body_container {
margin-left: 0;
.channel-name {
font-size: var(--font-xsmall);
}
&:last-of-type {
padding-top: var(--spacing-m);
}
}
.livestream__comment-author {
font-weight: var(--font-weight-bold);
color: #888;
.livestream-comment--superchat {
+ .livestream-comment--superchat {
margin-bottom: var(--spacing-xxs);
}
.livestream-comment__info {
margin-top: calc(var(--spacing-xxs) / 2);
}
&::before {
position: absolute;
left: 0;
height: 100%;
max-height: 4rem;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
width: 5px;
background-color: var(--color-superchat);
content: '';
}
}
.livestream__comment-author--streamer {
color: var(--color-primary);
.livestream-comment__body {
display: flex;
align-items: flex-start;
}
.livestream-comment__body {
display: flex;
align-items: flex-start;
margin-left: var(--spacing-s);
.channel-thumbnail {
margin-top: var(--spacing-xxs);
flex-shrink: 0;
}
}
.livestream-comment__menu {
position: absolute;
right: var(--spacing-xs);
top: var(--spacing-xs);
}
.livestream__comment-create {
margin-top: var(--spacing-s);
padding: var(--spacing-s);
border-top: 1px solid var(--color-border);
margin-top: auto;
}
.livestream__channel-link {
@ -119,93 +194,145 @@
}
}
.livestream__emoji-actions {
margin-bottom: var(--spacing-m);
> *:not(:last-child) {
margin-right: var(--spacing-s);
}
}
.livestream__embed-page {
display: flex;
.file-viewer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
iframe {
max-height: none;
}
}
}
.livestream__embed-wrapper {
height: 100vh;
width: 100vw;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
background-color: #000000;
.livestream {
margin-top: auto;
margin-bottom: auto;
}
}
.livestream__embed-countdown {
@extend .livestream__embed-wrapper;
justify-content: center;
}
.livestream__embed {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100vh;
width: 100vw;
}
.livestream__embed-comments {
width: 30vw;
height: 100vh;
display: none;
.livestream__discussion {
height: 100vh;
margin-left: 0;
}
.card {
border-radius: 0;
}
.card__main-actions {
height: 100%;
width: 30vw;
}
.livestream__comments-wrapper--with-height {
height: calc(100% - 200px - (var(--spacing-l)));
}
@media (min-width: $breakpoint-small) {
display: inline-block;
}
}
.livestream__publish-intro {
margin-top: var(--spacing-l);
}
.livestream__viewer-count {
display: flex;
align-items: center;
.icon {
margin-left: var(--spacing-xs);
}
}
.livestream-superchats__wrapper {
flex-shrink: 0;
position: relative;
overflow-x: scroll;
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);
@media (min-width: $breakpoint-small) {
padding: var(--spacing-xs);
width: var(--livestream-comments-width);
}
}
.livestream-superchat__amount-large {
.credit-amount {
display: flex;
align-items: center;
flex-wrap: nowrap;
}
}
.livestream-superchats__inner {
display: flex;
}
.livestream-superchat {
display: flex;
margin-right: var(--spacing-xs);
padding: var(--spacing-xxs);
border-radius: var(--border-radius);
.channel-thumbnail {
margin-right: var(--spacing-xs);
}
&:first-of-type {
background-color: var(--color-superchat);
.channel-name {
max-width: 8rem;
}
}
&:nth-of-type(2) {
background-color: var(--color-superchat-2);
}
&:nth-of-type(3) {
background-color: var(--color-superchat-3);
}
&:nth-of-type(-n + 3) {
.channel-name,
.credit-amount {
color: var(--color-black);
}
}
.channel-name {
max-width: 5rem;
}
}
.livestream-superchat__info {
display: flex;
flex-direction: column;
justify-content: center;
font-size: var(--font-xsmall);
}
.livestream-superchat__banner {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
padding: 0.25rem var(--spacing-s);
display: inline-block;
position: relative;
}
// This is just a two small circles that overlap to make it look like
// the banner and the left border are connected
.livestream-superchat__banner-corner {
height: calc(var(--border-radius) * 2);
width: calc(var(--border-radius) * 2);
border-radius: 50%;
position: absolute;
background-color: var(--color-superchat);
bottom: 0;
left: 0;
transform: translateX(25%) translateY(50%);
&::after {
content: '';
height: calc(var(--border-radius) * 2);
width: calc(var(--border-radius) * 2);
border-top-left-radius: var(--border-radius);
background-color: var(--color-card-background);
position: absolute;
bottom: 0;
left: 0;
transform: translateX(25%) translateY(50%);
}
}
.livestream-comment__text {
padding-right: var(--spacing-xxs);
padding-bottom: var(--spacing-xxs);
}
.livestream-superchat__tooltip-amount {
margin-top: var(--spacing-xs);
margin-left: 0;
background-color: transparent;
padding: 0;
}
.livestream__superchat-comment {
margin-top: var(--spacing-s);
max-width: 5rem;
overflow-wrap: break-word;
}
.livestream-superchat__amount-large {
min-width: 2.5rem;
}
.table--livestream-data {
td:nth-of-type(1) {
max-width: 4rem;

View file

@ -169,7 +169,14 @@
}
.main--livestream {
margin-top: var(--spacing-m);
width: calc(100vw - var(--spacing-xs) * 2);
margin: var(--spacing-xs);
@media (min-width: $breakpoint-small) {
margin: var(--spacing-m);
margin-right: calc(var(--livestream-comments-width) + var(--spacing-m));
width: calc(100vw - var(--livestream-comments-width) - var(--spacing-m) * 2);
}
}
.main--full-width {

View file

@ -60,6 +60,7 @@
justify-content: space-between;
align-items: flex-end;
flex-direction: row;
flex-wrap: wrap;
@media (max-width: $breakpoint-medium) {
display: block;

View file

@ -127,6 +127,8 @@
}
.recommended-content__toggles {
margin-right: var(--spacing-s);
button {
padding: 0 var(--spacing-xs);
height: 2rem;

View file

@ -0,0 +1,20 @@
.super-chat {
border-radius: var(--border-radius);
background: linear-gradient(to right, var(--color-superchat), var(--color-superchat-3));
padding: 0.2rem var(--spacing-xs);
font-weight: var(--font-weight-bold);
font-size: var(--font-xsmall);
.credit-amount {
color: var(--color-superchat-text);
}
}
.super-chat--light {
@extend .super-chat;
background: linear-gradient(to right, var(--color-superchat__light), var(--color-superchat-3__light));
.credit-amount {
color: var(--color-superchat-text__light);
}
}

View file

@ -7,11 +7,11 @@
overflow: hidden;
&:first-of-type {
padding-left: var(--spacing-l);
padding-left: var(--spacing-m);
}
&:last-of-type {
padding-right: var(--spacing-l);
padding-right: var(--spacing-m);
}
}
@ -118,7 +118,7 @@ td {
.table__header-text {
width: 100%;
margin: 0 var(--spacing-s);
margin-right: var(--spacing-s);
}
.table__header-text--between {

View file

@ -127,10 +127,10 @@
position: relative;
display: flex;
align-items: center;
margin-top: var(--spacing-l);
margin-top: var(--spacing-m);
~ .section {
margin-top: var(--spacing-l);
margin-top: var(--spacing-m);
}
&:only-child,
@ -139,7 +139,7 @@
}
> *:not(:last-child) {
margin-right: var(--spacing-m);
margin-right: var(--spacing-s);
}
@media (max-width: $breakpoint-small) {
@ -161,7 +161,6 @@
.button--primary ~ .button--link,
.button--secondary ~ .button--link {
margin-left: var(--spacing-s);
padding: var(--spacing-s) var(--spacing-m);
height: var(--button-height);
}

View file

@ -96,6 +96,8 @@ $breakpoint-large: 1600px;
--file-list-thumbnail-width: 10rem;
--tag-height: 1.5rem;
--livestream-comments-width: 30rem;
}
@media (max-width: $breakpoint-small) {

View file

@ -142,4 +142,14 @@
// Scrollbar
--color-scrollbar-thumb-bg: rgba(255, 255, 255, 0.2);
--color-scrollbar-track-bg: transparent;
// Superchat
--color-superchat-text: var(--color-black);
--color-superchat-text__light: var(--color-text);
--color-superchat: #fcd34d;
--color-superchat__light: #ef4e1647;
--color-superchat-2: #fde68a;
--color-superchat-3: #fef3c7;
--color-superchat-3__light: #58066087;
--color-superchat-4: #fffbeb;
}

View file

@ -108,4 +108,13 @@
// Scrollbar
--color-scrollbar-thumb-bg: rgba(0, 0, 0, 0.2);
--color-scrollbar-track-bg: transparent;
// Superchat
--color-superchat-text: var(--color-black);
--color-superchat: #fcd34d;
--color-superchat__light: #fcd34d50;
--color-superchat-2: #fde68a;
--color-superchat-3: #fef3c7;
--color-superchat-3__light: #fef3c750;
--color-superchat-4: #fffbeb;
}

View file

@ -1,46 +1,10 @@
// @flow
export function toHex(str: string): string {
const array = Array.from(str);
let s = unescape(encodeURIComponent(str));
let result = '';
for (var i = 0; i < array.length; i++) {
const val = array[i];
const utf = toUTF8Array(val)
.map((num) => num.toString(16))
.join('');
result += utf;
for (let i = 0; i < s.length; i++) {
result += s.charCodeAt(i).toString(16).padStart(2, '0');
}
return result;
}
// https://gist.github.com/joni/3760795
// See comment that fixes an issue in the original gist
function toUTF8Array(str: string): Array<number> {
var utf8 = [];
for (var i = 0; i < str.length; i++) {
var charcode = str.charCodeAt(i);
if (charcode < 0x80) utf8.push(charcode);
else if (charcode < 0x800) {
utf8.push(0xc0 | (charcode >> 6), 0x80 | (charcode & 0x3f));
} else if (charcode < 0xd800 || charcode >= 0xe000) {
utf8.push(0xe0 | (charcode >> 12), 0x80 | ((charcode >> 6) & 0x3f), 0x80 | (charcode & 0x3f));
}
// surrogate pair
else {
i++;
charcode = (((charcode & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff)) + 0x010000;
utf8.push(
0xf0 | (charcode >> 18),
0x80 | ((charcode >> 12) & 0x3f),
0x80 | ((charcode >> 6) & 0x3f),
0x80 | (charcode & 0x3f)
);
}
}
return utf8;
}

View file

@ -8,7 +8,7 @@ function encodeWithApostropheEncode(string) {
return encodeURIComponent(string).replace(/'/g, '%27');
}
export const formatLbryUrlForWeb = uri => {
export const formatLbryUrlForWeb = (uri) => {
let newUrl = uri.replace('lbry://', '/').replace(/#/g, ':');
if (newUrl.startsWith('/?')) {
// This is a lbry link to an internal page ex: lbry://?rewards
@ -18,7 +18,7 @@ export const formatLbryUrlForWeb = uri => {
return newUrl;
};
export const formatFileSystemPath = path => {
export const formatFileSystemPath = (path) => {
if (!path) {
return;
}
@ -37,7 +37,7 @@ export const formatFileSystemPath = path => {
ex: lbry://?rewards
ex: open.lbry.com/?rewards
*/
export const formatInAppUrl = path => {
export const formatInAppUrl = (path) => {
// Determine if we need to add a leading "/$/" for app pages
const APP_PAGE_REGEX = /(\?)([a-z]*)(.*)/;
const appPageMatches = APP_PAGE_REGEX.exec(path);
@ -75,7 +75,7 @@ export const formatWebUrlIntoLbryUrl = (pathname, search) => {
return appLink;
};
export const generateInitialUrl = hash => {
export const generateInitialUrl = (hash) => {
let url = '/';
if (hash) {
hash = hash.replace('#', '');
@ -88,7 +88,7 @@ export const generateLbryContentUrl = (canonicalUrl, permanentUrl) => {
return canonicalUrl ? canonicalUrl.split('lbry://')[1] : permanentUrl.split('lbry://')[1];
};
export const generateLbryWebUrl = lbryUrl => {
export const generateLbryWebUrl = (lbryUrl) => {
return lbryUrl.replace(/#/g, ':');
};

View file

@ -6951,9 +6951,9 @@ lazy-val@^1.0.4:
yargs "^13.2.2"
zstd-codec "^0.1.1"
lbry-redux@lbryio/lbry-redux#eb37009a987410a60e9f2ba79708049c9904687c:
lbry-redux@lbryio/lbry-redux#7e173446838b381491492526ff29ca8312819879:
version "0.0.1"
resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/eb37009a987410a60e9f2ba79708049c9904687c"
resolved "https://codeload.github.com/lbryio/lbry-redux/tar.gz/7e173446838b381491492526ff29ca8312819879"
dependencies:
proxy-polyfill "0.1.6"
reselect "^3.0.0"