mirror of
https://github.com/wassname/talk.git
synced 2026-08-18 12:30:39 +08:00
Merge branch 'master' into i18n-refactor
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import {ADD_EXTERNAL_CONFIG} from '../constants/config';
|
||||
|
||||
export const addExternalConfig = (config) => ({
|
||||
type: ADD_EXTERNAL_CONFIG,
|
||||
config
|
||||
});
|
||||
@@ -7,4 +7,3 @@ export const setActiveTab = (tab) => (dispatch, getState) => {
|
||||
dispatch(viewAllComments());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,13 +13,87 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
.bylineSecondary {
|
||||
color: #696969;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.editedMarker {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* element in the top right of the Comment */
|
||||
.topRight {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.topRight > * {
|
||||
text-align: initial;
|
||||
}
|
||||
|
||||
.topRight .popover {
|
||||
margin-top: 1em;
|
||||
right: 0px;
|
||||
}
|
||||
|
||||
.topRight .link.active,
|
||||
.topRight .active .link {
|
||||
padding-bottom: 0.125em;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.topRightMenu {
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.topRightMenu > * {
|
||||
text-align: initial;
|
||||
.editCommentForm {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.editCommentForm .buttonContainerLeft {
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editCommentForm .buttonContainerLeft .editWindowRemaining {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.editCommentForm .button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editWindowAlmostOver {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Wizard used for Ignore User, Delete Comment confirmations */
|
||||
.Wizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px; /* consider moving to better class */
|
||||
}
|
||||
|
||||
.Wizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.Wizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import LoadMore from './LoadMore';
|
||||
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
|
||||
import {TopRightMenu} from './TopRightMenu';
|
||||
import classnames from 'classnames';
|
||||
import {EditableCommentContent} from './EditableCommentContent';
|
||||
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
|
||||
|
||||
import {getEditableUntilDate} from './util';
|
||||
import styles from './Comment.css';
|
||||
|
||||
const isStaff = tags => !tags.every(t => t.name !== 'STAFF');
|
||||
const isStaff = (tags) => !tags.every((t) => t.name !== 'STAFF');
|
||||
|
||||
// hold actions links (e.g. Reply) along the comment footer
|
||||
const ActionButton = ({children}) => {
|
||||
@@ -37,7 +39,17 @@ const ActionButton = ({children}) => {
|
||||
class Comment extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {replyBoxVisible: false};
|
||||
|
||||
// timeout to keep track of Comment edit window expiration
|
||||
this.editWindowExpiryTimeout = null;
|
||||
this.onClickEdit = this.onClickEdit.bind(this);
|
||||
this.stopEditing = this.stopEditing.bind(this);
|
||||
this.state = {
|
||||
|
||||
// Whether the comment should be editable (e.g. after a commenter clicking the 'Edit' button on their own comment)
|
||||
isEditing: false,
|
||||
replyBoxVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
@@ -53,7 +65,7 @@ class Comment extends React.Component {
|
||||
parentId: PropTypes.string,
|
||||
highlighted: PropTypes.string,
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
depth: PropTypes.number.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
@@ -84,7 +96,13 @@ class Comment extends React.Component {
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}).isRequired,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
|
||||
// ISO8601
|
||||
editableUntil: PropTypes.string,
|
||||
})
|
||||
}).isRequired,
|
||||
|
||||
// given a comment, return whether it should be rendered as ignored
|
||||
@@ -97,17 +115,53 @@ class Comment extends React.Component {
|
||||
removeCommentTag: React.PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func
|
||||
};
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
render() {
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
}
|
||||
|
||||
onClickEdit (e) {
|
||||
e.preventDefault();
|
||||
this.setState({isEditing: true});
|
||||
}
|
||||
|
||||
stopEditing () {
|
||||
if (this._isMounted) {
|
||||
this.setState({isEditing: false});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
|
||||
// if still in the edit window, set a timeout to re-render once it expires
|
||||
const msLeftToEdit = editWindowRemainingMs(this.props.comment);
|
||||
if (msLeftToEdit > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
|
||||
// re-render
|
||||
this.setState(this.state);
|
||||
}, msLeftToEdit);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
this._isMounted = false;
|
||||
}
|
||||
render () {
|
||||
const {
|
||||
comment,
|
||||
parentId,
|
||||
currentUser,
|
||||
asset,
|
||||
depth,
|
||||
postItem,
|
||||
postComment,
|
||||
addNotification,
|
||||
showSignInDialog,
|
||||
highlighted,
|
||||
@@ -133,9 +187,9 @@ class Comment extends React.Component {
|
||||
);
|
||||
let myFlag = null;
|
||||
if (iPerformedThisAction('FlagActionSummary', comment)) {
|
||||
myFlag = flagSummary.find(s => s.current_user);
|
||||
myFlag = flagSummary.find((s) => s.current_user);
|
||||
} else if (iPerformedThisAction('DontAgreeActionSummary', comment)) {
|
||||
myFlag = dontAgreeSummary.find(s => s.current_user);
|
||||
myFlag = dontAgreeSummary.find((s) => s.current_user);
|
||||
}
|
||||
|
||||
let commentClass = parentId
|
||||
@@ -147,7 +201,7 @@ class Comment extends React.Component {
|
||||
const notifyOnError = (fn, errorToMessage) =>
|
||||
async function(...args) {
|
||||
if (typeof errorToMessage !== 'function') {
|
||||
errorToMessage = error => error.message;
|
||||
errorToMessage = (error) => error.message;
|
||||
}
|
||||
try {
|
||||
return await fn(...args);
|
||||
@@ -190,8 +244,17 @@ class Comment extends React.Component {
|
||||
|
||||
{commentIsBest(comment)
|
||||
? <TagLabel><BestIndicator /></TagLabel>
|
||||
: null}
|
||||
<PubDate created_at={comment.created_at} />
|
||||
: null }
|
||||
|
||||
<span className={styles.bylineSecondary}>
|
||||
<PubDate created_at={comment.created_at} />
|
||||
{
|
||||
(comment.editing && comment.editing.edited)
|
||||
? <span> <span className={styles.editedMarker}>(Edited)</span></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
|
||||
<Slot
|
||||
fill="commentInfoBar"
|
||||
data={this.props.data}
|
||||
@@ -200,18 +263,47 @@ class Comment extends React.Component {
|
||||
commentId={comment.id}
|
||||
inline
|
||||
/>
|
||||
{currentUser && comment.user.id !== currentUser.id
|
||||
? <span className={styles.topRightMenu}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification}
|
||||
/>
|
||||
</span>
|
||||
: null}
|
||||
|
||||
<Content body={comment.body} />
|
||||
<Slot fill="commentContent" />
|
||||
{ (currentUser &&
|
||||
(comment.user.id === currentUser.id))
|
||||
|
||||
/* User can edit/delete their own comment for a short window after posting */
|
||||
? <span className={classnames(styles.topRight)}>
|
||||
{
|
||||
commentIsStillEditable(comment) &&
|
||||
<a
|
||||
className={classnames(styles.link, {[styles.active]: this.state.isEditing})}
|
||||
onClick={this.onClickEdit}>Edit</a>
|
||||
}
|
||||
</span>
|
||||
|
||||
/* TopRightMenu allows currentUser to ignore other users' comments */
|
||||
: <span className={classnames(styles.topRight, styles.topRightMenu)}>
|
||||
<TopRightMenu
|
||||
comment={comment}
|
||||
ignoreUser={ignoreUser}
|
||||
addNotification={addNotification} />
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
this.state.isEditing
|
||||
? <EditableCommentContent
|
||||
editComment={this.props.editComment.bind(null, comment.id, asset.id)}
|
||||
addNotification={addNotification}
|
||||
asset={asset}
|
||||
comment={comment}
|
||||
currentUser={currentUser}
|
||||
maxCharCount={maxCharCount}
|
||||
parentId={parentId}
|
||||
stopEditing={this.stopEditing}
|
||||
/>
|
||||
: <div>
|
||||
<Content body={comment.body} />
|
||||
<Slot fill="commentContent" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="commentActionsLeft comment__action-container">
|
||||
<Slot
|
||||
fill="commentReactions"
|
||||
@@ -278,12 +370,12 @@ class Comment extends React.Component {
|
||||
parentId={parentId || comment.id}
|
||||
addNotification={addNotification}
|
||||
authorId={currentUser.id}
|
||||
postItem={postItem}
|
||||
postComment={postComment}
|
||||
assetId={asset.id}
|
||||
/>
|
||||
: null}
|
||||
{comment.replies &&
|
||||
comment.replies.map(reply => {
|
||||
comment.replies.map((reply) => {
|
||||
return commentIsIgnored(reply)
|
||||
? <IgnoredCommentTombstone key={reply.id} />
|
||||
: <Comment
|
||||
@@ -294,7 +386,8 @@ class Comment extends React.Component {
|
||||
activeReplyBox={activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
parentId={comment.id}
|
||||
postItem={postItem}
|
||||
postComment={postComment}
|
||||
editComment={this.props.editComment}
|
||||
depth={depth + 1}
|
||||
asset={asset}
|
||||
highlighted={highlighted}
|
||||
@@ -330,3 +423,21 @@ class Comment extends React.Component {
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
|
||||
// return whether the comment is editable
|
||||
function commentIsStillEditable (comment) {
|
||||
const editing = comment && comment.editing;
|
||||
if (!editing) {return false;}
|
||||
const editableUntil = getEditableUntilDate(comment);
|
||||
const editWindowExpired = (editableUntil - new Date) < 0;
|
||||
return !editWindowExpired;
|
||||
}
|
||||
|
||||
// return number of milliseconds before edit window expires
|
||||
function editWindowRemainingMs (comment) {
|
||||
const editableUntil = getEditableUntilDate(comment);
|
||||
if (!editableUntil) {return;}
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = (editableUntil - now);
|
||||
return editWindowRemainingMs;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Countdown the number of seconds until a given Date
|
||||
*/
|
||||
export class CountdownSeconds extends React.Component {
|
||||
static propTypes = {
|
||||
until: PropTypes.instanceOf(Date).isRequired,
|
||||
classNameForMsRemaining: PropTypes.func,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.countdownInterval = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
const {until} = this.props;
|
||||
const now = new Date();
|
||||
if (until - now > 0) {
|
||||
this.countdownInterval = setInterval(() => {
|
||||
|
||||
// re-render
|
||||
this.forceUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.countdownInterval) {
|
||||
this.countdownInterval = clearInterval(this.countdownInterval);
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const now = new Date();
|
||||
const {until, classNameForMsRemaining} = this.props;
|
||||
const msRemaining = until - now;
|
||||
const secRemaining = msRemaining / 1000;
|
||||
const wholeSecRemaining = Math.floor(secRemaining);
|
||||
const plural = secRemaining !== 1;
|
||||
const units = lang.t(plural ? 'editComment.secondsPlural' : 'editComment.second');
|
||||
let classFromProp;
|
||||
if (typeof classNameForMsRemaining === 'function') {
|
||||
classFromProp = classNameForMsRemaining(msRemaining);
|
||||
}
|
||||
return (
|
||||
<span className={classFromProp}>
|
||||
{`${wholeSecRemaining} ${units}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {notifyForNewCommentStatus} from 'coral-plugin-commentbox/CommentBox';
|
||||
import {CommentForm} from 'coral-plugin-commentbox/CommentForm';
|
||||
import styles from './Comment.css';
|
||||
import {CountdownSeconds} from './CountdownSeconds';
|
||||
import {getEditableUntilDate} from './util';
|
||||
|
||||
import {Icon} from 'coral-ui';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-framework/translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
/**
|
||||
* Renders a Comment's body in such a way that the end-user can edit it and save changes
|
||||
*/
|
||||
export class EditableCommentContent extends React.Component {
|
||||
static propTypes = {
|
||||
|
||||
// show notification to the user (e.g. for errors)
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
settings: PropTypes.shape({
|
||||
charCountEnable: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
|
||||
// comment that is being edited
|
||||
comment: PropTypes.shape({
|
||||
body: PropTypes.string,
|
||||
editing: PropTypes.shape({
|
||||
edited: PropTypes.bool,
|
||||
|
||||
// ISO8601
|
||||
editableUntil: PropTypes.string,
|
||||
})
|
||||
}).isRequired,
|
||||
|
||||
// logged in user
|
||||
currentUser: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired
|
||||
}),
|
||||
maxCharCount: PropTypes.number,
|
||||
|
||||
// edit a comment, passed {{ body }}
|
||||
editComment: React.PropTypes.func,
|
||||
|
||||
// called when editing should be stopped
|
||||
stopEditing: React.PropTypes.func,
|
||||
}
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.editComment = this.editComment.bind(this);
|
||||
this.editWindowExpiryTimeout = null;
|
||||
}
|
||||
componentDidMount() {
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const now = new Date();
|
||||
const editWindowRemainingMs = editableUntil && (editableUntil - now);
|
||||
if (editWindowRemainingMs > 0) {
|
||||
this.editWindowExpiryTimeout = setTimeout(() => {
|
||||
this.forceUpdate();
|
||||
}, editWindowRemainingMs);
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
if (this.editWindowExpiryTimeout) {
|
||||
this.editWindowExpiryTimeout = clearTimeout(this.editWindowExpiryTimeout);
|
||||
}
|
||||
}
|
||||
async editComment(edit) {
|
||||
const {editComment, addNotification, stopEditing} = this.props;
|
||||
if (typeof editComment !== 'function') {return;}
|
||||
let response;
|
||||
let successfullyEdited = false;
|
||||
try {
|
||||
response = await editComment(edit);
|
||||
const errors = (response && response.data && response.data.editComment)
|
||||
? response.data.editComment.errors
|
||||
: null;
|
||||
if (errors && (errors.length === 1)) {
|
||||
throw errors[0];
|
||||
}
|
||||
successfullyEdited = true;
|
||||
} catch (error) {
|
||||
if (error.translation_key) {
|
||||
addNotification('error', lang.t(`error.${error.translation_key}`));
|
||||
} else if (error.networkError) {
|
||||
addNotification('error', lang.t('error.networkError'));
|
||||
} else {
|
||||
addNotification('error', lang.t('editComment.unexpectedError'));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (successfullyEdited) {
|
||||
const status = response.data.editComment.comment.status;
|
||||
notifyForNewCommentStatus(this.props.addNotification, status);
|
||||
}
|
||||
if (successfullyEdited && typeof stopEditing === 'function') {
|
||||
stopEditing();
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const originalBody = this.props.comment.body;
|
||||
const editableUntil = getEditableUntilDate(this.props.comment);
|
||||
const editWindowExpired = (editableUntil - new Date()) < 0;
|
||||
return (
|
||||
<div className={styles.editCommentForm}>
|
||||
<CommentForm
|
||||
defaultValue={this.props.comment.body}
|
||||
charCountEnable={this.props.asset.settings.charCountEnable}
|
||||
maxCharCount={this.props.maxCharCount}
|
||||
saveCommentEnabled={(comment) => {
|
||||
|
||||
// should be disabled if user hasn't actually changed their
|
||||
// original comment
|
||||
return (comment.body !== originalBody) && !editWindowExpired;
|
||||
}}
|
||||
saveComment={this.editComment}
|
||||
bodyLabel={lang.t('editComment.bodyInputLabel')}
|
||||
bodyPlaceholder=""
|
||||
submitText={<span>{lang.t('editComment.saveButton')}</span>}
|
||||
saveButtonCStyle="green"
|
||||
cancelButtonClicked={this.props.stopEditing}
|
||||
buttonClass={styles.button}
|
||||
buttonContainerStart={
|
||||
<div className={styles.buttonContainerLeft}>
|
||||
<span className={styles.editWindowRemaining}>
|
||||
{
|
||||
editWindowExpired
|
||||
? <span>
|
||||
{lang.t('editComment.editWindowExpired')}
|
||||
{
|
||||
typeof this.props.stopEditing === 'function'
|
||||
? <span> <a className={styles.link} onClick={this.props.stopEditing}>{lang.t('editComment.editWindowExpiredClose')}</a></span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
: <span>
|
||||
<Icon name="timer"/> {lang.t('editComment.editWindowTimerPrefix')}
|
||||
<CountdownSeconds
|
||||
until={editableUntil}
|
||||
classNameForMsRemaining={(remainingMs) => (remainingMs <= 10 * 1000) ? styles.editWindowAlmostOver : '' }
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.IgnoreUserWizard {
|
||||
background-color: #2E343B;
|
||||
color: white;
|
||||
padding: 1em;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.IgnoreUserWizard .textAlignRight {
|
||||
text-align: right;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './IgnoreUserWizard.css';
|
||||
import styles from './Comment.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
// Guides the user through ignoring another user, including confirming their decision
|
||||
@@ -58,7 +58,7 @@ export class IgnoreUserWizard extends React.Component {
|
||||
const {step} = this.state;
|
||||
const elForThisStep = elsForStep[step - 1];
|
||||
return (
|
||||
<div className={styles.IgnoreUserWizard}>
|
||||
<div className={styles.Wizard}>
|
||||
{ elForThisStep }
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import ChangeUsernameContainer
|
||||
from 'coral-sign-in/containers/ChangeUsernameContainer';
|
||||
|
||||
class Stream extends React.Component {
|
||||
setActiveReplyBox = reactKey => {
|
||||
setActiveReplyBox = (reactKey) => {
|
||||
if (!this.props.auth.user) {
|
||||
this.props.showSignInDialog();
|
||||
} else {
|
||||
@@ -26,7 +26,7 @@ class Stream extends React.Component {
|
||||
render() {
|
||||
const {
|
||||
root: {asset, asset: {comments}, comment, myIgnoredUsers},
|
||||
postItem,
|
||||
postComment,
|
||||
addNotification,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
@@ -58,7 +58,7 @@ class Stream extends React.Component {
|
||||
const firstCommentDate = asset.comments[0]
|
||||
? asset.comments[0].created_at
|
||||
: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString();
|
||||
const commentIsIgnored = comment =>
|
||||
const commentIsIgnored = (comment) =>
|
||||
myIgnoredUsers && myIgnoredUsers.includes(comment.user.id);
|
||||
return (
|
||||
<div id="stream">
|
||||
@@ -84,7 +84,7 @@ class Stream extends React.Component {
|
||||
{user
|
||||
? <CommentBox
|
||||
addNotification={this.props.addNotification}
|
||||
postItem={this.props.postItem}
|
||||
postComment={this.props.postComment}
|
||||
appendItemArray={this.props.appendItemArray}
|
||||
updateItem={this.props.updateItem}
|
||||
setCommentCountCache={this.props.setCommentCountCache}
|
||||
@@ -122,7 +122,7 @@ class Stream extends React.Component {
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={this.props.postItem}
|
||||
postComment={this.props.postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
highlighted={comment.id}
|
||||
@@ -137,6 +137,7 @@ class Stream extends React.Component {
|
||||
comment={highlightedComment}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
/>
|
||||
: <div>
|
||||
<NewCount
|
||||
@@ -149,7 +150,7 @@ class Stream extends React.Component {
|
||||
/>
|
||||
<div className="embed__stream">
|
||||
{comments.map(
|
||||
comment =>
|
||||
(comment) =>
|
||||
(commentIsIgnored(comment)
|
||||
? <IgnoredCommentTombstone key={comment.id} />
|
||||
: <Comment
|
||||
@@ -160,7 +161,7 @@ class Stream extends React.Component {
|
||||
activeReplyBox={this.props.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
depth={0}
|
||||
postItem={postItem}
|
||||
postComment={postComment}
|
||||
asset={asset}
|
||||
currentUser={user}
|
||||
postFlag={postFlag}
|
||||
@@ -178,6 +179,7 @@ class Stream extends React.Component {
|
||||
pluginProps={pluginProps}
|
||||
charCountEnable={asset.settings.charCountEnable}
|
||||
maxCharCount={asset.settings.charCount}
|
||||
editComment={this.props.editComment}
|
||||
/>)
|
||||
)}
|
||||
</div>
|
||||
@@ -196,7 +198,7 @@ class Stream extends React.Component {
|
||||
|
||||
Stream.propTypes = {
|
||||
addNotification: PropTypes.func.isRequired,
|
||||
postItem: PropTypes.func.isRequired,
|
||||
postComment: PropTypes.func.isRequired,
|
||||
|
||||
// dispatch action to add a tag to a comment
|
||||
addCommentTag: PropTypes.func,
|
||||
@@ -205,7 +207,10 @@ Stream.propTypes = {
|
||||
removeCommentTag: PropTypes.func,
|
||||
|
||||
// dispatch action to ignore another user
|
||||
ignoreUser: React.PropTypes.func
|
||||
ignoreUser: React.PropTypes.func,
|
||||
|
||||
// edit a comment, passed (id, asset_id, { body })
|
||||
editComment: React.PropTypes.func,
|
||||
};
|
||||
|
||||
export default Stream;
|
||||
|
||||
@@ -20,5 +20,4 @@
|
||||
position: relative;
|
||||
transform: rotate(180deg);
|
||||
top: 0;
|
||||
/*top: -0.25em;*/
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ class Toggleable extends React.Component {
|
||||
};
|
||||
}
|
||||
toggle() {
|
||||
this.setState({isOpen: ! this.state.isOpen});
|
||||
this.setState({isOpen: !this.state.isOpen});
|
||||
}
|
||||
close() {
|
||||
this.setState({isOpen: false});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Given a comment, return when the comment can no longer be edited
|
||||
* @param {Object} comment
|
||||
* @returns {Date} when the comment can no longer be edited.
|
||||
*/
|
||||
export const getEditableUntilDate = (comment) => {
|
||||
const editing = comment && comment.editing;
|
||||
const editableUntil = editing && editing.editableUntil && new Date(Date.parse(editing.editableUntil));
|
||||
return editableUntil;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const ADD_EXTERNAL_CONFIG = 'ADD_EXTERNAL_CONFIG';
|
||||
@@ -1,6 +1,6 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import Comment from '../components/Comment';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getSlotsFragments} from 'coral-framework/helpers/plugins';
|
||||
|
||||
const pluginFragments = getSlotsFragments([
|
||||
@@ -41,6 +41,10 @@ export default withFragments({
|
||||
id
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
${pluginFragments.spreads('comment')}
|
||||
}
|
||||
${pluginFragments.definitions('comment')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import {compose, gql, graphql} from 'react-apollo';
|
||||
import {compose, gql} from 'react-apollo';
|
||||
import {connect} from 'react-redux';
|
||||
import {bindActionCreators} from 'redux';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
@@ -8,31 +8,28 @@ import renderComponent from 'recompose/renderComponent';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
import {authActions, assetActions, pym} from 'coral-framework';
|
||||
import {getDefinitionName, separateDataAndRoot} from 'coral-framework/utils';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
import {withQuery} from 'coral-framework/hocs';
|
||||
import Embed from '../components/Embed';
|
||||
import {setCommentCountCache, viewAllComments} from '../actions/stream';
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
import Stream from './Stream';
|
||||
|
||||
import {setActiveTab} from '../actions/embed';
|
||||
import {setCommentCountCache, viewAllComments} from '../actions/stream';
|
||||
|
||||
const {logout, checkLogin} = authActions;
|
||||
const {fetchAssetSuccess} = assetActions;
|
||||
|
||||
class EmbedContainer extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
pym.sendMessage('childReady');
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if(this.props.root.me && !nextProps.root.me) {
|
||||
if (this.props.auth.loggedIn !== nextProps.auth.loggedIn) {
|
||||
|
||||
// Refetch because on logout `excludeIgnored` becomes `false`.
|
||||
// TODO: logout via mutation and obsolete this?
|
||||
// Refetch after login/logout.
|
||||
this.props.data.refetch();
|
||||
}
|
||||
|
||||
const {fetchAssetSuccess} = this.props;
|
||||
if(!isEqual(nextProps.root.asset, this.props.root.asset)) {
|
||||
if (!isEqual(nextProps.root.asset, this.props.root.asset)) {
|
||||
|
||||
// TODO: remove asset data from redux store.
|
||||
fetchAssetSuccess(nextProps.root.asset);
|
||||
@@ -47,7 +44,7 @@ class EmbedContainer extends React.Component {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if(!isEqual(prevProps.root.comment, this.props.root.comment)) {
|
||||
if (!isEqual(prevProps.root.comment, this.props.root.comment)) {
|
||||
|
||||
// Scroll to a permalinked comment if one is in the URL once the page is done rendering.
|
||||
setTimeout(() => pym.scrollParentToChildEl('coralStream'), 0);
|
||||
@@ -75,7 +72,7 @@ const EMBED_QUERY = gql`
|
||||
${Stream.fragments.root}
|
||||
`;
|
||||
|
||||
export const withQuery = graphql(EMBED_QUERY, {
|
||||
export const withEmbedQuery = withQuery(EMBED_QUERY, {
|
||||
options: ({auth, commentId, assetId, assetUrl}) => ({
|
||||
variables: {
|
||||
assetId,
|
||||
@@ -85,34 +82,33 @@ export const withQuery = graphql(EMBED_QUERY, {
|
||||
excludeIgnored: Boolean(auth && auth.user && auth.user.id),
|
||||
},
|
||||
}),
|
||||
props: ({data}) => separateDataAndRoot(data),
|
||||
});
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth.toJS(),
|
||||
commentCountCache: state.stream.commentCountCache,
|
||||
commentId: state.stream.commentId,
|
||||
assetId: state.stream.assetId,
|
||||
assetUrl: state.stream.assetUrl,
|
||||
activeTab: state.embed.activeTab,
|
||||
config: state.config
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
bindActionCreators({
|
||||
fetchAssetSuccess,
|
||||
checkLogin,
|
||||
setCommentCountCache,
|
||||
viewAllComments,
|
||||
logout,
|
||||
setActiveTab,
|
||||
}, dispatch);
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators(
|
||||
{
|
||||
logout,
|
||||
checkLogin,
|
||||
setActiveTab,
|
||||
viewAllComments,
|
||||
fetchAssetSuccess,
|
||||
setCommentCountCache
|
||||
},
|
||||
dispatch
|
||||
);
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
branch(
|
||||
props => !props.auth.checkedInitialLogin,
|
||||
renderComponent(Spinner),
|
||||
),
|
||||
withQuery,
|
||||
branch((props) => !props.auth.checkedInitialLogin && props.config, renderComponent(Spinner)),
|
||||
withEmbedQuery,
|
||||
)(EmbedContainer);
|
||||
|
||||
|
||||
@@ -6,13 +6,17 @@ import uniqBy from 'lodash/uniqBy';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import isNil from 'lodash/isNil';
|
||||
import {NEW_COMMENT_COUNT_POLL_INTERVAL} from '../constants/stream';
|
||||
import {postComment, postFlag, postDontAgree, deleteAction, addCommentTag, removeCommentTag, ignoreUser} from 'coral-framework/graphql/mutations';
|
||||
import {
|
||||
withPostComment, withPostFlag, withPostDontAgree, withDeleteAction,
|
||||
withAddCommentTag, withRemoveCommentTag, withIgnoreUser, withEditComment,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
|
||||
import {notificationActions, authActions} from 'coral-framework';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {setCommentCountCache, setActiveReplyBox} from '../actions/stream';
|
||||
import Stream from '../components/Stream';
|
||||
import Comment from './Comment';
|
||||
import withFragments from 'coral-framework/hocs/withFragments';
|
||||
import {withFragments} from 'coral-framework/hocs';
|
||||
import {getDefinitionName} from 'coral-framework/utils';
|
||||
|
||||
const {showSignInDialog} = authActions;
|
||||
@@ -25,7 +29,7 @@ class StreamContainer extends React.Component {
|
||||
variables,
|
||||
|
||||
// Apollo requires this, even though we don't use it...
|
||||
updateQuery: data => data,
|
||||
updateQuery: (data) => data,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -75,7 +79,7 @@ class StreamContainer extends React.Component {
|
||||
...oldData,
|
||||
asset: {
|
||||
...oldData.asset,
|
||||
comments: oldData.asset.comments.map(comment => {
|
||||
comments: oldData.asset.comments.map((comment) => {
|
||||
|
||||
// since the dipslayed replies and the returned replies can overlap,
|
||||
// pull out the unique ones.
|
||||
@@ -199,10 +203,6 @@ const fragments = {
|
||||
}
|
||||
}
|
||||
}
|
||||
myIgnoredUsers {
|
||||
id,
|
||||
username,
|
||||
}
|
||||
me {
|
||||
status
|
||||
}
|
||||
@@ -213,7 +213,7 @@ const fragments = {
|
||||
`,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
const mapStateToProps = (state) => ({
|
||||
auth: state.auth.toJS(),
|
||||
commentCountCache: state.stream.commentCountCache,
|
||||
activeReplyBox: state.stream.activeReplyBox,
|
||||
@@ -224,7 +224,7 @@ const mapStateToProps = state => ({
|
||||
previousTab: state.embed.previousTab,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch =>
|
||||
const mapDispatchToProps = (dispatch) =>
|
||||
bindActionCreators({
|
||||
showSignInDialog,
|
||||
addNotification,
|
||||
@@ -236,12 +236,13 @@ const mapDispatchToProps = dispatch =>
|
||||
export default compose(
|
||||
withFragments(fragments),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
postComment,
|
||||
postFlag,
|
||||
postDontAgree,
|
||||
addCommentTag,
|
||||
removeCommentTag,
|
||||
ignoreUser,
|
||||
deleteAction,
|
||||
withPostComment,
|
||||
withPostFlag,
|
||||
withPostDontAgree,
|
||||
withAddCommentTag,
|
||||
withRemoveCommentTag,
|
||||
withIgnoreUser,
|
||||
withDeleteAction,
|
||||
withEditComment,
|
||||
)(StreamContainer);
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import {gql} from 'react-apollo';
|
||||
import {add} from 'coral-framework/services/graphqlRegistry';
|
||||
import update from 'immutability-helper';
|
||||
|
||||
const extension = {
|
||||
fragments: {
|
||||
EditCommentResponse: gql`
|
||||
fragment CoralEmbedStream_EditCommentResponse on EditCommentResponse {
|
||||
comment {
|
||||
status
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
StopIgnoringUserResponse: gql`
|
||||
fragment CoralEmbedStream_StopIgnoringUserResponse on StopIgnoringUserResponse {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
IgnoreUserResponse: gql`
|
||||
fragment CoralEmbedStream_IgnoreUserResponse on IgnoreUserResponse {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
RemoveCommentTagResponse: gql`
|
||||
fragment CoralEmbedStream_RemoveCommentTagResponse on RemoveCommentTagResponse {
|
||||
comment {
|
||||
id
|
||||
tags {
|
||||
name
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
AddCommentTagResponse: gql`
|
||||
fragment CoralEmbedStream_AddCommentTagResponse on AddCommentTagResponse {
|
||||
comment {
|
||||
id
|
||||
tags {
|
||||
name
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
DeleteActionResponse: gql`
|
||||
fragment CoralEmbedStream_DeleteActionResponse on DeleteActionResponse {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
CreateFlagResponse: gql`
|
||||
fragment CoralEmbedStream_CreateFlagResponse on CreateFlagResponse {
|
||||
flag {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
CreateDontAgreeResponse : gql`
|
||||
fragment CoralEmbedStream_CreateDontAgreeResponse on CreateDontAgreeResponse {
|
||||
dontagree {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
`,
|
||||
CreateCommentResponse: gql`
|
||||
fragment CoralEmbedStream_CreateCommentResponse on CreateCommentResponse {
|
||||
comment {
|
||||
...CoralEmbedStream_CreateCommentResponse_Comment
|
||||
replies {
|
||||
...CoralEmbedStream_CreateCommentResponse_Comment
|
||||
}
|
||||
}
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
|
||||
fragment CoralEmbedStream_CreateCommentResponse_Comment on Comment {
|
||||
id
|
||||
body
|
||||
created_at
|
||||
status
|
||||
replyCount
|
||||
tags {
|
||||
name
|
||||
}
|
||||
user {
|
||||
id
|
||||
name: username
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
current_user {
|
||||
id
|
||||
created_at
|
||||
}
|
||||
}
|
||||
editing {
|
||||
edited
|
||||
editableUntil
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
mutations: {
|
||||
IgnoreUser: () => ({
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'EmbedStreamProfileQuery',
|
||||
],
|
||||
}),
|
||||
StopIgnoringUser: () => ({
|
||||
|
||||
// TODO: don't rely on refetching.
|
||||
refetchQueries: [
|
||||
'EmbedQuery', 'EmbedStreamProfileQuery',
|
||||
],
|
||||
}),
|
||||
PostComment: ({
|
||||
variables: {comment: {asset_id, body, parent_id, tags = []}},
|
||||
state: {auth},
|
||||
}) => ({
|
||||
optimisticResponse: {
|
||||
createComment: {
|
||||
comment: {
|
||||
user: {
|
||||
id: auth.toJS().user.id,
|
||||
name: auth.toJS().user.username
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
body,
|
||||
parent_id,
|
||||
asset_id,
|
||||
action_summaries: [],
|
||||
tags,
|
||||
status: null,
|
||||
id: 'pending'
|
||||
}
|
||||
}
|
||||
},
|
||||
updateQueries: {
|
||||
EmbedQuery: (previousData, {mutationResult: {data: {createComment: {comment}}}}) => {
|
||||
if (previousData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
|
||||
return previousData;
|
||||
}
|
||||
|
||||
let updatedAsset;
|
||||
|
||||
// If posting a reply
|
||||
if (parent_id) {
|
||||
updatedAsset = {
|
||||
...previousData,
|
||||
asset: {
|
||||
...previousData.asset,
|
||||
comments: previousData.asset.comments.map((oldComment) => {
|
||||
return oldComment.id === parent_id
|
||||
? {...oldComment, replies: [...oldComment.replies, comment], replyCount: oldComment.replyCount + 1}
|
||||
: oldComment;
|
||||
})
|
||||
}
|
||||
};
|
||||
} else {
|
||||
|
||||
// If posting a top-level comment
|
||||
updatedAsset = {
|
||||
...previousData,
|
||||
asset: {
|
||||
...previousData.asset,
|
||||
commentCount: previousData.asset.commentCount + 1,
|
||||
comments: [comment, ...previousData.asset.comments]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return updatedAsset;
|
||||
}
|
||||
}
|
||||
}),
|
||||
EditComment: ({
|
||||
variables: {id, edit},
|
||||
}) => ({
|
||||
updateQueries: {
|
||||
EmbedQuery: (previousData, {mutationResult: {data: {editComment: {comment: {status}}}}}) => {
|
||||
const updateCommentWithEdit = (comment, edit) => {
|
||||
const {body} = edit;
|
||||
const editedComment = update(comment, {
|
||||
$merge: {
|
||||
body
|
||||
},
|
||||
editing: {$merge:{edited:true}}
|
||||
});
|
||||
return editedComment;
|
||||
};
|
||||
const commentIsStillVisible = (comment) => {
|
||||
return !((id === comment.id) && (['PREMOD', 'REJECTED'].includes(status)));
|
||||
};
|
||||
const resultReflectingEdit = update(previousData, {
|
||||
asset: {
|
||||
comments: {
|
||||
$apply: (comments) => {
|
||||
return comments.filter(commentIsStillVisible).map((comment) => {
|
||||
let replyWasEditedToBeHidden = false;
|
||||
if (comment.id === id) {
|
||||
return updateCommentWithEdit(comment, edit);
|
||||
}
|
||||
const commentWithUpdatedReplies = update(comment, {
|
||||
replies: {
|
||||
$apply: (comments) => {
|
||||
return comments
|
||||
.filter((c) => {
|
||||
if (commentIsStillVisible(c)) {
|
||||
return true;
|
||||
}
|
||||
replyWasEditedToBeHidden = true;
|
||||
return false;
|
||||
})
|
||||
.map((comment) => {
|
||||
if (comment.id === id) {
|
||||
return updateCommentWithEdit(comment, edit);
|
||||
}
|
||||
return comment;
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// If a reply was edited to be hdiden, then this parent needs its replyCount to be decremented.
|
||||
if (replyWasEditedToBeHidden) {
|
||||
return update(commentWithUpdatedReplies, {
|
||||
replyCount: {
|
||||
$apply: (replyCount) => {
|
||||
return replyCount - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return commentWithUpdatedReplies;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return resultReflectingEdit;
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
add(extension);
|
||||
@@ -4,10 +4,13 @@ import {ApolloProvider} from 'react-apollo';
|
||||
|
||||
import {client} from 'coral-framework/services/client';
|
||||
import {checkLogin} from 'coral-framework/actions/auth';
|
||||
import './graphql';
|
||||
import {addExternalConfig} from 'coral-embed-stream/src/actions/config';
|
||||
|
||||
import reducers from './reducers';
|
||||
import localStore, {injectReducers} from 'coral-framework/services/store';
|
||||
import AppRouter from './AppRouter';
|
||||
import {pym} from 'coral-framework';
|
||||
|
||||
injectReducers(reducers);
|
||||
|
||||
@@ -16,6 +19,12 @@ const store = (window.opener && window.opener.coralStore) ? window.opener.coralS
|
||||
// Don't run this in the popup.
|
||||
if (store === localStore) {
|
||||
store.dispatch(checkLogin());
|
||||
|
||||
pym.sendMessage('getConfig');
|
||||
|
||||
pym.onMessage('config', (config) => {
|
||||
store.dispatch(addExternalConfig(JSON.parse(config)));
|
||||
});
|
||||
}
|
||||
|
||||
render(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import {ADD_EXTERNAL_CONFIG} from '../constants/config';
|
||||
|
||||
const initialState = {};
|
||||
|
||||
export default function config(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ADD_EXTERNAL_CONFIG:
|
||||
return {
|
||||
...state,
|
||||
...action.config
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import stream from './stream';
|
||||
import embed from './embed';
|
||||
import config from './config';
|
||||
import stream from './stream';
|
||||
|
||||
export default {
|
||||
stream,
|
||||
embed,
|
||||
stream,
|
||||
config
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user