diff --git a/.eslintrc.json b/.eslintrc.json index 50e4be5cb..106d125f6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,6 +16,7 @@ }, "rules": { "brace-style": 0, + "camelcase": 0, "comma-dangle": ["error", "always-multiline"], "handle-callback-err": 0, "indent": 0, diff --git a/ui/component/commentsList/index.js b/ui/component/commentsList/index.js index 6d5f3929b..e10ec1383 100644 --- a/ui/component/commentsList/index.js +++ b/ui/component/commentsList/index.js @@ -1,11 +1,7 @@ import { connect } from 'react-redux'; -import { - makeSelectCommentsForUri, - doCommentList, - makeSelectClaimIsMine, - selectMyChannelClaims, - selectIsFetchingComments, -} from 'lbry-redux'; +import { makeSelectClaimIsMine, selectMyChannelClaims } from 'lbry-redux'; +import { makeSelectCommentsForUri, selectIsFetchingComments } from 'redux/selectors/comments'; +import { doCommentList } from 'redux/actions/comments'; import CommentsList from './view'; const select = (state, props) => ({ diff --git a/ui/constants/action_types.js b/ui/constants/action_types.js index aae2b98aa..1588104b8 100644 --- a/ui/constants/action_types.js +++ b/ui/constants/action_types.js @@ -229,3 +229,20 @@ export const CREATE_TOAST = 'CREATE_TOAST'; export const DISMISS_TOAST = 'DISMISS_TOAST'; export const CREATE_ERROR = 'CREATE_ERROR'; export const DISMISS_ERROR = 'DISMISS_ERROR'; + +// Comments +export const COMMENT_LIST_STARTED = 'COMMENT_LIST_STARTED'; +export const COMMENT_LIST_COMPLETED = 'COMMENT_LIST_COMPLETED'; +export const COMMENT_LIST_FAILED = 'COMMENT_LIST_FAILED'; +export const COMMENT_CREATE_STARTED = 'COMMENT_CREATE_STARTED'; +export const COMMENT_CREATE_COMPLETED = 'COMMENT_CREATE_COMPLETED'; +export const COMMENT_CREATE_FAILED = 'COMMENT_CREATE_FAILED'; +export const COMMENT_ABANDON_STARTED = 'COMMENT_ABANDON_STARTED'; +export const COMMENT_ABANDON_COMPLETED = 'COMMENT_ABANDON_COMPLETED'; +export const COMMENT_ABANDON_FAILED = 'COMMENT_ABANDON_FAILED'; +export const COMMENT_UPDATE_STARTED = 'COMMENT_UPDATE_STARTED'; +export const COMMENT_UPDATE_COMPLETED = 'COMMENT_UPDATE_COMPLETED'; +export const COMMENT_UPDATE_FAILED = 'COMMENT_UPDATE_FAILED'; +export const COMMENT_HIDE_STARTED = 'COMMENT_HIDE_STARTED'; +export const COMMENT_HIDE_COMPLETED = 'COMMENT_HIDE_COMPLETED'; +export const COMMENT_HIDE_FAILED = 'COMMENT_HIDE_FAILED'; diff --git a/ui/reducers.js b/ui/reducers.js index 641dbda22..1f255c96e 100644 --- a/ui/reducers.js +++ b/ui/reducers.js @@ -6,7 +6,6 @@ import { searchReducer, walletReducer, tagsReducer, - commentReducer, blockedReducer, publishReducer, } from 'lbry-redux'; @@ -26,6 +25,7 @@ import subscriptionsReducer from 'redux/reducers/subscriptions'; import notificationsReducer from 'redux/reducers/notifications'; import rewardsReducer from 'redux/reducers/rewards'; import userReducer from 'redux/reducers/user'; +import commentsReducer from 'redux/reducers/comments'; export default history => combineReducers({ @@ -34,7 +34,7 @@ export default history => blacklist: blacklistReducer, filtered: filteredReducer, claims: claimsReducer, - comments: commentReducer, + comments: commentsReducer, content: contentReducer, costInfo: costInfoReducer, fileInfo: fileInfoReducer, diff --git a/ui/redux/actions/comments.js b/ui/redux/actions/comments.js new file mode 100644 index 000000000..49f41afef --- /dev/null +++ b/ui/redux/actions/comments.js @@ -0,0 +1,217 @@ +// @flow +import * as ACTIONS from 'constants/action_types'; +import { Lbry, selectClaimsByUri, selectMyChannelClaims } from 'lbry-redux'; +import { doToast } from 'redux/actions/notifications'; + +export function doCommentList(uri: string, page: number = 1, pageSize: number = 99999) { + return (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + const claim = selectClaimsByUri(state)[uri]; + const claimId = claim ? claim.claim_id : null; + + dispatch({ + type: ACTIONS.COMMENT_LIST_STARTED, + }); + Lbry.comment_list({ + claim_id: claimId, + page, + page_size: pageSize, + }) + .then((result: CommentListResponse) => { + const { items: comments } = result; + dispatch({ + type: ACTIONS.COMMENT_LIST_COMPLETED, + data: { + comments, + claimId: claimId, + uri: uri, + }, + }); + }) + .catch(error => { + dispatch({ + type: ACTIONS.COMMENT_LIST_FAILED, + data: error, + }); + }); + }; +} + +export function doCommentCreate(comment: string = '', claim_id: string = '', channel: string, parent_id?: string) { + return (dispatch: Dispatch, getState: GetState) => { + const state = getState(); + dispatch({ + type: ACTIONS.COMMENT_CREATE_STARTED, + }); + + const myChannels = selectMyChannelClaims(state); + const namedChannelClaim = myChannels && myChannels.find(myChannel => myChannel.name === channel); + const channel_id = namedChannelClaim.claim_id; + + if (channel_id == null) { + dispatch({ + type: ACTIONS.COMMENT_CREATE_FAILED, + data: {}, + }); + dispatch( + doToast({ + message: 'Channel cannot be anonymous, please select a channel and try again.', + isError: true, + }) + ); + return; + } + + return Lbry.comment_create({ + comment: comment, + claim_id: claim_id, + channel_id: channel_id, + parent_id: parent_id, + }) + .then((result: CommentCreateResponse) => { + dispatch({ + type: ACTIONS.COMMENT_CREATE_COMPLETED, + data: { + comment: result, + claimId: claim_id, + }, + }); + }) + .catch(error => { + dispatch({ + type: ACTIONS.COMMENT_CREATE_FAILED, + data: error, + }); + dispatch( + doToast({ + message: 'Unable to create comment, please try again later.', + isError: true, + }) + ); + }); + }; +} + +export function doCommentHide(comment_id: string) { + return (dispatch: Dispatch) => { + dispatch({ + type: ACTIONS.COMMENT_HIDE_STARTED, + }); + return Lbry.comment_hide({ + comment_ids: [comment_id], + }) + .then((result: CommentHideResponse) => { + dispatch({ + type: ACTIONS.COMMENT_HIDE_COMPLETED, + data: result, + }); + }) + .catch(error => { + dispatch({ + type: ACTIONS.COMMENT_HIDE_FAILED, + data: error, + }); + dispatch( + doToast({ + message: 'Unable to hide this comment, please try again later.', + isError: true, + }) + ); + }); + }; +} + +export function doCommentAbandon(comment_id: string) { + return (dispatch: Dispatch) => { + dispatch({ + type: ACTIONS.COMMENT_ABANDON_STARTED, + }); + return Lbry.comment_abandon({ + comment_id: comment_id, + }) + .then((result: CommentAbandonResponse) => { + // Comment may not be deleted if the signing channel can't be signed. + // This will happen if the channel was recently created or abandoned. + if (result.abandoned) { + dispatch({ + type: ACTIONS.COMMENT_ABANDON_COMPLETED, + data: { + comment_id: comment_id, + }, + }); + } else { + dispatch({ + type: ACTIONS.COMMENT_ABANDON_FAILED, + }); + dispatch( + doToast({ + message: 'Your channel is still being setup, try again in a few moments.', + isError: true, + }) + ); + } + }) + .catch(error => { + dispatch({ + type: ACTIONS.COMMENT_ABANDON_FAILED, + data: error, + }); + dispatch( + doToast({ + message: 'Unable to delete this comment, please try again later.', + isError: true, + }) + ); + }); + }; +} + +export function doCommentUpdate(comment_id: string, comment: string) { + // if they provided an empty string, they must have wanted to abandon + if (comment === '') { + return doCommentAbandon(comment_id); + } else { + return (dispatch: Dispatch) => { + dispatch({ + type: ACTIONS.COMMENT_UPDATE_STARTED, + }); + return Lbry.comment_update({ + comment_id: comment_id, + comment: comment, + }) + .then((result: CommentUpdateResponse) => { + if (result != null) { + dispatch({ + type: ACTIONS.COMMENT_UPDATE_COMPLETED, + data: { + comment: result, + }, + }); + } else { + // the result will return null + dispatch({ + type: ACTIONS.COMMENT_UPDATE_FAILED, + }); + dispatch( + doToast({ + message: 'Your channel is still being setup, try again in a few moments.', + isError: true, + }) + ); + } + }) + .catch(error => { + dispatch({ + type: ACTIONS.COMMENT_UPDATE_FAILED, + data: error, + }); + dispatch( + doToast({ + message: 'Unable to edit this comment, please try again later.', + isError: true, + }) + ); + }); + }; + } +} diff --git a/ui/redux/reducers/comments.js b/ui/redux/reducers/comments.js new file mode 100644 index 000000000..5ddb7d310 --- /dev/null +++ b/ui/redux/reducers/comments.js @@ -0,0 +1,153 @@ +// @flow +import * as ACTIONS from 'constants/action_types'; +import { handleActions } from 'util/redux-utils'; + +const defaultState: CommentsState = { + commentById: {}, // commentId -> Comment + byId: {}, // ClaimID -> list of comments + commentsByUri: {}, // URI -> claimId + isLoading: false, + myComments: undefined, +}; + +export default handleActions( + { + [ACTIONS.COMMENT_CREATE_STARTED]: (state: CommentsState, action: any): CommentsState => ({ + ...state, + isLoading: true, + }), + + [ACTIONS.COMMENT_CREATE_FAILED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: false, + }), + + [ACTIONS.COMMENT_CREATE_COMPLETED]: (state: CommentsState, action: any): CommentsState => { + const { comment, claimId }: { comment: Comment, claimId: string } = action.data; + const commentById = Object.assign({}, state.commentById); + const byId = Object.assign({}, state.byId); + const comments = byId[claimId]; + const newCommentIds = comments.slice(); + + // add the comment by its ID + commentById[comment.comment_id] = comment; + + // push the comment_id to the top of ID list + newCommentIds.unshift(comment.comment_id); + byId[claimId] = newCommentIds; + + return { + ...state, + commentById, + byId, + isLoading: false, + }; + }, + + [ACTIONS.COMMENT_LIST_STARTED]: state => ({ ...state, isLoading: true }), + + [ACTIONS.COMMENT_LIST_COMPLETED]: (state: CommentsState, action: any) => { + const { comments, claimId, uri } = action.data; + + const commentById = Object.assign({}, state.commentById); + const byId = Object.assign({}, state.byId); + const commentsByUri = Object.assign({}, state.commentsByUri); + + if (comments) { + // we use an Array to preserve order of listing + // in reality this doesn't matter and we can just + // sort comments by their timestamp + const commentIds = Array(comments.length); + + // map the comment_ids to the new comments + for (let i = 0; i < comments.length; i++) { + commentIds[i] = comments[i].comment_id; + commentById[commentIds[i]] = comments[i]; + } + + byId[claimId] = commentIds; + commentsByUri[uri] = claimId; + } + return { + ...state, + byId, + commentById, + commentsByUri, + isLoading: false, + }; + }, + + [ACTIONS.COMMENT_LIST_FAILED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: false, + }), + [ACTIONS.COMMENT_ABANDON_STARTED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: true, + }), + [ACTIONS.COMMENT_ABANDON_COMPLETED]: (state: CommentsState, action: any) => { + const { comment_id } = action.data; + const commentById = Object.assign({}, state.commentById); + const byId = Object.assign({}, state.byId); + + // to remove the comment and its references + const claimId = commentById[comment_id].claim_id; + for (let i = 0; i < byId[claimId].length; i++) { + if (byId[claimId][i] === comment_id) { + byId[claimId].splice(i, 1); + break; + } + } + delete commentById[comment_id]; + + return { + ...state, + commentById, + byId, + isLoading: false, + }; + }, + // do nothing + [ACTIONS.COMMENT_ABANDON_FAILED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: false, + }), + // do nothing + [ACTIONS.COMMENT_UPDATE_STARTED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: true, + }), + // replace existing comment with comment returned here under its comment_id + [ACTIONS.COMMENT_UPDATE_COMPLETED]: (state: CommentsState, action: any) => { + const { comment } = action.data; + const commentById = Object.assign({}, state.commentById); + commentById[comment.comment_id] = comment; + + return { + ...state, + commentById, + isLoading: false, + }; + }, + // nothing can be done here + [ACTIONS.COMMENT_UPDATE_FAILED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: false, + }), + // nothing can really be done here + [ACTIONS.COMMENT_HIDE_STARTED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: true, + }), + [ACTIONS.COMMENT_HIDE_COMPLETED]: (state: CommentsState, action: any) => ({ + ...state, // todo: add HiddenComments state & create selectors + isLoading: false, + }), + // nothing can be done here + [ACTIONS.COMMENT_HIDE_FAILED]: (state: CommentsState, action: any) => ({ + ...state, + isLoading: false, + }), + }, + defaultState +); diff --git a/ui/redux/selectors/comments.js b/ui/redux/selectors/comments.js new file mode 100644 index 000000000..269882bb8 --- /dev/null +++ b/ui/redux/selectors/comments.js @@ -0,0 +1,54 @@ +// @flow +import { createSelector } from 'reselect'; + +const selectState = state => state.comments || {}; + +export const selectCommentsById = createSelector(selectState, state => state.commentById || {}); + +export const selectIsFetchingComments = createSelector(selectState, state => state.isLoading); + +export const selectCommentsByClaimId = createSelector(selectState, selectCommentsById, (state, byId) => { + const byClaimId = state.byId || {}; + const comments = {}; + + // replace every comment_id in the list with the actual comment object + Object.keys(byClaimId).forEach(claimId => { + const commentIds = byClaimId[claimId]; + + comments[claimId] = Array(commentIds === null ? 0 : commentIds.length); + for (let i = 0; i < commentIds.length; i++) { + comments[claimId][i] = byId[commentIds[i]]; + } + }); + + return comments; +}); + +// previously this used a mapping from claimId -> Array +/* export const selectCommentsById = createSelector( + selectState, + state => state.byId || {} +); */ +export const selectCommentsByUri = createSelector(selectState, state => { + const byUri = state.commentsByUri || {}; + const comments = {}; + Object.keys(byUri).forEach(uri => { + const claimId = byUri[uri]; + if (claimId === null) { + comments[uri] = null; + } else { + comments[uri] = claimId; + } + }); + + return comments; +}); + +export const makeSelectCommentsForUri = (uri: string) => + createSelector(selectCommentsByClaimId, selectCommentsByUri, (byClaimId, byUri) => { + const claimId = byUri[uri]; + return byClaimId && byClaimId[claimId]; + }); + +// todo: allow SDK to retrieve user comments through comment_list +// todo: implement selectors for selecting comments owned by user