Merge branch 'develop' into studio-selectors

This commit is contained in:
Paul Kaplan 2021-04-02 12:36:44 -04:00
commit dc460fffde
28 changed files with 1509 additions and 1016 deletions

View file

@ -81,7 +81,7 @@ aliases:
- run:
name: "setup python"
command: |
curl https://bootstrap.pypa.io/3.5/get-pip.py -o get-pip.py
curl https://bootstrap.pypa.io/pip/3.5/get-pip.py -o get-pip.py
python3 get-pip.py pip==21.0.1
pip install s3cmd==2.1.0
- run:
@ -134,7 +134,7 @@ jobs:
# <<: *integration_tap
workflows:
build-staging-production: # build-test-deploy
build-test-deploy:
jobs:
- build-staging:
context:
@ -154,42 +154,39 @@ workflows:
branches:
only:
- master
# - deploy-staging:
# context:
# - scratch-www-all
# - scratch-www-staging
# requires:
# - build-staging
# filters:
# branches:
# only:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
# - integration-staging-jest:
# context:
# - scratch-www-all
# - scratch-www-staging
# requires:
# - deploy-staging
# filters:
# branches:
# only:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
# - integration-staging-tap:
# context:
# - scratch-www-all
# - scratch-www-staging
# requires:
# - deploy-staging
# filters:
# branches:
# only:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
- deploy-staging:
context:
- scratch-www-all
- scratch-www-staging
requires:
- build-staging
filters:
branches:
only:
- develop
- /^hotfix\/.*/
- /^release\/.*/
- integration-staging-jest:
context:
- scratch-www-all
- scratch-www-staging
requires:
- deploy-staging
filters:
branches:
only:
- develop
- /^hotfix\/.*/
- /^release\/.*/
- integration-staging-tap:
context:
- scratch-www-all
- scratch-www-staging
requires:
- deploy-staging
filters:
branches:
only:
- develop
- /^hotfix\/.*/
- /^release\/.*/

View file

@ -114,15 +114,6 @@ jobs:
include:
- stage: test
deploy:
- provider: script
skip_cleanup: $SKIP_CLEANUP
script: npm run deploy
on:
repo: LLK/scratch-www
branch:
- develop
- hotfix/*
- release/*
- provider: script
skip_cleanup: $SKIP_CLEANUP
script: npm run deploy
@ -138,6 +129,6 @@ stages:
- name: test
if: type != cron
- name: smoke
if: type NOT IN (cron, pull_request) AND (branch =~ /^(develop|master|release\/|hotfix\/)/)
if: type NOT IN (cron, pull_request) AND (branch =~ /^(master)/)
- name: update translations
if: branch == develop AND type == cron

842
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,9 @@
"test:unit:jest": "npm run test:unit:jest:unit && npm run test:unit:jest:localization",
"test:unit:jest:unit": "jest ./test/unit/ --reporters=default",
"test:unit:jest:localization": "jest ./test/localization/*.test.js --reporters=default",
"test:unit:tap": "tap ./test/{unit-legacy,localization-legacy}/*.js --no-coverage -R classic",
"test:unit:tap": "tap ./test/{unit-legacy,localization-legacy}/ --no-coverage -R classic",
"test:unit:convertReportToXunit": "tap ./test/results/unit-raw.tap --no-coverage -R xunit > ./test/results/unit-tap-results.xml",
"test:coverage": "tap ./test/{unit-legacy,localization-legacy}/*.js --coverage --coverage-report=lcov",
"test:coverage": "tap ./test/{unit-legacy,localization-legacy}/ --coverage --coverage-report=lcov",
"build": "npm run clean && npm run translate && NODE_OPTIONS=--max_old_space_size=8000 webpack --bail",
"clean": "rm -rf ./build && rm -rf ./intl && mkdir -p build && mkdir -p intl",
"deploy": "npm run deploy:s3 && npm run deploy:fastly",
@ -126,7 +126,7 @@
"redux-mock-store": "^1.2.3",
"redux-thunk": "2.0.1",
"sass-loader": "6.0.6",
"scratch-gui": "0.1.0-prerelease.20210322040541",
"scratch-gui": "0.1.0-prerelease.20210401231322",
"scratch-l10n": "latest",
"selenium-webdriver": "3.6.0",
"slick-carousel": "1.6.0",

View file

@ -4,47 +4,53 @@
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 2,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 3,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 4,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 5,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
},
{
"id": 6,
"type": "project",
"title": "Project",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": "",
"href": "#",
"stats": {"loves": 0, "remixes": 0}
}
]

View file

@ -2,129 +2,145 @@
{
"id": 1,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 2,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 3,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 4,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 5,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 6,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 7,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 8,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 9,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 10,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 11,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 12,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 13,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 14,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 15,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 16,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
}
]

View file

@ -327,8 +327,6 @@
"comments.isBad": "Hmm...the bad word detector thinks there is a problem with your comment. Please change it and remember to be respectful.",
"comments.hasChatSite": "Uh oh! The comment contains a link to a website with unmoderated chat. For safety reasons, please do not link to these sites!",
"comments.isSpam": "Hmm, seems like you've posted the same comment a bunch of times. Please don't spam.",
"comments.isMuted": "Hmm, the filterbot is pretty sure your recent comments weren't ok for Scratch, so your account has been muted for the rest of the day. :/",
"comments.isUnconstructive": "Hmm, the filterbot thinks your comment may be mean or disrespectful. Remember, most projects on Scratch are made by people who are just learning how to program.",
"comments.isDisallowed": "Hmm, it looks like comments have been turned off for this page. :/",
"comments.isIPMuted": "Sorry, the Scratch Team had to prevent your network from sharing comments or projects because it was used to break our community guidelines too many times. You can still share comments and projects from another network. If you'd like to appeal this block, you can contact appeals@scratch.mit.edu and reference Case Number {appealId}.",
"comments.isTooLong": "That comment is too long! Please find a way to shorten your text.",

188
src/redux/comments.js Normal file
View file

@ -0,0 +1,188 @@
const keyMirror = require('keymirror');
const mergeWith = require('lodash.mergewith');
const uniqBy = require('lodash.uniqby');
const COMMENT_LIMIT = 20;
module.exports.Status = keyMirror({
FETCHED: null,
NOT_FETCHED: null,
FETCHING: null,
ERROR: null
});
module.exports.getInitialState = () => ({
status: {
comments: module.exports.Status.NOT_FETCHED
},
comments: [],
replies: {},
moreCommentsToLoad: false
});
module.exports.commentsReducer = (state, action) => {
if (typeof state === 'undefined') {
state = module.exports.getInitialState();
}
switch (action.type) {
case 'RESET_TO_INTIAL_STATE':
return module.exports.getInitialState();
case 'RESET_COMMENTS':
return Object.assign({}, state, {
comments: [],
replies: {}
});
case 'SET_COMMENT_FETCH_STATUS':
return Object.assign({}, state, {
status: Object.assign({}, state.status, {
[action.infoType]: action.status
})
});
case 'SET_COMMENTS':
return Object.assign({}, state, {
comments: uniqBy(state.comments.concat(action.items), 'id')
});
case 'UPDATE_COMMENT':
if (action.topLevelCommentId) {
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
[action.topLevelCommentId]: state.replies[action.topLevelCommentId].map(comment => {
if (comment.id === action.commentId) {
return Object.assign({}, comment, action.comment);
}
return comment;
})
})
});
}
return Object.assign({}, state, {
comments: state.comments.map(comment => {
if (comment.id === action.commentId) {
return Object.assign({}, comment, action.comment);
}
return comment;
})
});
case 'ADD_NEW_COMMENT':
if (action.topLevelCommentId) {
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
// Replies to comments go at the end of the thread
[action.topLevelCommentId]: state.replies[action.topLevelCommentId].concat(action.comment)
})
});
}
// Reply to the top level project, put the reply at the beginning
return Object.assign({}, state, {
comments: [action.comment, ...state.comments],
replies: Object.assign({}, state.replies, {[action.comment.id]: []})
});
case 'UPDATE_ALL_REPLIES':
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
[action.commentId]: state.replies[action.commentId].map(reply =>
Object.assign({}, reply, action.comment)
)
})
});
case 'SET_REPLIES':
return Object.assign({}, state, {
// Append new replies to the state.replies structure
replies: mergeWith({}, state.replies, action.replies, (replies, newReplies) => (
uniqBy((replies || []).concat(newReplies || []), 'id')
)),
// Also set the `moreRepliesToLoad` property on the top-level comments
comments: state.comments.map(comment => {
if (action.replies[comment.id]) {
return Object.assign({}, comment, {
moreRepliesToLoad: action.replies[comment.id].length === COMMENT_LIMIT
});
}
return comment;
})
});
case 'SET_MORE_COMMENTS_TO_LOAD':
return Object.assign({}, state, {
moreCommentsToLoad: action.moreCommentsToLoad
});
default:
return state;
}
};
module.exports.setFetchStatus = (type, status) => ({
type: 'SET_COMMENT_FETCH_STATUS',
infoType: type,
status: status
});
module.exports.setComments = items => ({
type: 'SET_COMMENTS',
items: items
});
module.exports.setReplies = replies => ({
type: 'SET_REPLIES',
replies: replies
});
module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'deleted'
}
});
module.exports.setRepliesDeleted = commentId => ({
type: 'UPDATE_ALL_REPLIES',
commentId: commentId,
comment: {
visibility: 'deleted'
}
});
module.exports.setCommentReported = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'reported'
}
});
module.exports.setCommentRestored = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'visible'
}
});
module.exports.setRepliesRestored = commentId => ({
type: 'UPDATE_ALL_REPLIES',
commentId: commentId,
comment: {
visibility: 'visible'
}
});
module.exports.addNewComment = (comment, topLevelCommentId) => ({
type: 'ADD_NEW_COMMENT',
comment: comment,
topLevelCommentId: topLevelCommentId
});
module.exports.setMoreCommentsToLoad = moreCommentsToLoad => ({
type: 'SET_MORE_COMMENTS_TO_LOAD',
moreCommentsToLoad: moreCommentsToLoad
});
module.exports.resetComments = () => ({
type: 'RESET_COMMENTS'
});

View file

@ -1,14 +1,9 @@
const defaults = require('lodash.defaults');
const keyMirror = require('keymirror');
const eachLimit = require('async/eachLimit');
const mergeWith = require('lodash.mergewith');
const uniqBy = require('lodash.uniqby');
const api = require('../lib/api');
const log = require('../lib/log');
const COMMENT_LIMIT = 20;
module.exports.Status = keyMirror({
FETCHED: null,
NOT_FETCHED: null,
@ -19,7 +14,6 @@ module.exports.Status = keyMirror({
module.exports.getInitialState = () => ({
status: {
project: module.exports.Status.NOT_FETCHED,
comments: module.exports.Status.NOT_FETCHED,
faved: module.exports.Status.NOT_FETCHED,
loved: module.exports.Status.NOT_FETCHED,
original: module.exports.Status.NOT_FETCHED,
@ -33,8 +27,6 @@ module.exports.getInitialState = () => ({
},
projectInfo: {},
remixes: [],
comments: [],
replies: {},
faved: false,
loved: false,
original: {},
@ -42,7 +34,6 @@ module.exports.getInitialState = () => ({
projectStudios: [],
curatedStudios: [],
currentStudioIds: [],
moreCommentsToLoad: false,
projectNotAvailable: false,
visibilityInfo: {}
});
@ -96,76 +87,6 @@ module.exports.previewReducer = (state, action) => {
item !== action.studioId
))
});
case 'RESET_COMMENTS':
return Object.assign({}, state, {
comments: [],
replies: {}
});
case 'SET_COMMENTS':
return Object.assign({}, state, {
comments: uniqBy(state.comments.concat(action.items), 'id')
});
case 'UPDATE_COMMENT':
if (action.topLevelCommentId) {
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
[action.topLevelCommentId]: state.replies[action.topLevelCommentId].map(comment => {
if (comment.id === action.commentId) {
return Object.assign({}, comment, action.comment);
}
return comment;
})
})
});
}
return Object.assign({}, state, {
comments: state.comments.map(comment => {
if (comment.id === action.commentId) {
return Object.assign({}, comment, action.comment);
}
return comment;
})
});
case 'ADD_NEW_COMMENT':
if (action.topLevelCommentId) {
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
// Replies to comments go at the end of the thread
[action.topLevelCommentId]: state.replies[action.topLevelCommentId].concat(action.comment)
})
});
}
// Reply to the top level project, put the reply at the beginning
return Object.assign({}, state, {
comments: [action.comment, ...state.comments],
replies: Object.assign({}, state.replies, {[action.comment.id]: []})
});
case 'UPDATE_ALL_REPLIES':
return Object.assign({}, state, {
replies: Object.assign({}, state.replies, {
[action.commentId]: state.replies[action.commentId].map(reply =>
Object.assign({}, reply, action.comment)
)
})
});
case 'SET_REPLIES':
return Object.assign({}, state, {
// Append new replies to the state.replies structure
replies: mergeWith({}, state.replies, action.replies, (replies, newReplies) => (
uniqBy((replies || []).concat(newReplies || []), 'id')
)),
// Also set the `moreRepliesToLoad` property on the top-level comments
comments: state.comments.map(comment => {
if (action.replies[comment.id]) {
return Object.assign({}, comment, {
moreRepliesToLoad: action.replies[comment.id].length === COMMENT_LIMIT
});
}
return comment;
})
});
case 'SET_LOVED':
return Object.assign({}, state, {
loved: action.info
@ -182,10 +103,6 @@ module.exports.previewReducer = (state, action) => {
state = JSON.parse(JSON.stringify(state));
state.status.studioRequests[action.studioId] = action.status;
return state;
case 'SET_MORE_COMMENTS_TO_LOAD':
return Object.assign({}, state, {
moreCommentsToLoad: action.moreCommentsToLoad
});
case 'SET_VISIBILITY_INFO':
return Object.assign({}, state, {
visibilityInfo: action.visibilityInfo
@ -247,16 +164,6 @@ module.exports.setProjectStudios = items => ({
items: items
});
module.exports.setComments = items => ({
type: 'SET_COMMENTS',
items: items
});
module.exports.setReplies = replies => ({
type: 'SET_REPLIES',
replies: replies
});
module.exports.setCuratedStudios = items => ({
type: 'SET_CURATED_STUDIOS',
items: items
@ -284,64 +191,6 @@ module.exports.setStudioFetchStatus = (studioId, status) => ({
status: status
});
module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'deleted'
}
});
module.exports.setRepliesDeleted = commentId => ({
type: 'UPDATE_ALL_REPLIES',
commentId: commentId,
comment: {
visibility: 'deleted'
}
});
module.exports.setCommentReported = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'reported'
}
});
module.exports.setCommentRestored = (commentId, topLevelCommentId) => ({
type: 'UPDATE_COMMENT',
commentId: commentId,
topLevelCommentId: topLevelCommentId,
comment: {
visibility: 'visible'
}
});
module.exports.setRepliesRestored = commentId => ({
type: 'UPDATE_ALL_REPLIES',
commentId: commentId,
comment: {
visibility: 'visible'
}
});
module.exports.addNewComment = (comment, topLevelCommentId) => ({
type: 'ADD_NEW_COMMENT',
comment: comment,
topLevelCommentId: topLevelCommentId
});
module.exports.setMoreCommentsToLoad = moreCommentsToLoad => ({
type: 'SET_MORE_COMMENTS_TO_LOAD',
moreCommentsToLoad: moreCommentsToLoad
});
module.exports.resetComments = () => ({
type: 'RESET_COMMENTS'
});
module.exports.setVisibilityInfo = visibilityInfo => ({
type: 'SET_VISIBILITY_INFO',
visibilityInfo: visibilityInfo
@ -462,94 +311,6 @@ module.exports.getFavedStatus = (id, username, token) => (dispatch => {
});
});
module.exports.getTopLevelComments = (id, offset, ownerUsername, isAdmin, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${id}/comments`,
authentication: token ? token : null,
params: {offset: offset || 0, limit: COMMENT_LIMIT}
}, (err, body, res) => {
if (err) {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError('No comment info'));
return;
}
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED));
dispatch(module.exports.setComments(body));
dispatch(module.exports.getReplies(id, body.map(comment => comment.id), 0, ownerUsername, isAdmin, token));
// If we loaded a full page of comments, assume there are more to load.
// This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require
// any more server query complexity, so seems worth it. In the case of a project with
// number of comments divisible by the COMMENT_LIMIT, the load more button will be
// clickable, but upon clicking it will go away.
dispatch(module.exports.setMoreCommentsToLoad(body.length === COMMENT_LIMIT));
});
});
module.exports.getCommentById = (projectId, commentId, ownerUsername, isAdmin, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${commentId}`,
authentication: token ? token : null
}, (err, body, res) => {
if (err) {
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
if (!body || res.statusCode >= 400) { // NotFound
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR));
dispatch(module.exports.setError('No comment info'));
return;
}
if (body.parent_id) {
// If the comment is a reply, load the parent
return dispatch(module.exports.getCommentById(projectId, body.parent_id, ownerUsername, isAdmin, token));
}
// If the comment is not a reply, show it as top level and load replies
dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED));
dispatch(module.exports.setComments([body]));
dispatch(module.exports.getReplies(projectId, [body.id], 0, ownerUsername, isAdmin, token));
});
});
module.exports.getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING));
const fetchedReplies = {};
eachLimit(commentIds, 10, (parentId, callback) => {
api({
uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`,
authentication: token ? token : null,
params: {offset: offset || 0, limit: COMMENT_LIMIT}
}, (err, body, res) => {
if (err) {
return callback(`Error fetching comment replies: ${err}`);
}
if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound
return callback('No comment reply information');
}
fetchedReplies[parentId] = body;
callback(null, body);
});
}, err => {
if (err) {
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.ERROR));
dispatch(module.exports.setError(err));
return;
}
dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHED));
dispatch(module.exports.setReplies(fetchedReplies));
});
});
module.exports.setFavedStatus = (faved, id, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('faved', module.exports.Status.FETCHING));
if (faved) {
@ -882,62 +643,6 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
});
});
module.exports.deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
/* TODO fetching/fetched/error states updates for comment deleting */
api({
uri: `/proxy/comments/project/${projectId}/comment/${commentId}`,
authentication: token,
withCredentials: true,
method: 'DELETE',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(module.exports.setCommentDeleted(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(module.exports.setRepliesDeleted(commentId));
}
});
});
module.exports.reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/project/${projectId}/comment/${commentId}/report`,
authentication: token,
withCredentials: true,
method: 'POST',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
// TODO use the reportId in the response for unreporting functionality
dispatch(module.exports.setCommentReported(commentId, topLevelCommentId));
});
});
module.exports.restoreComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/admin/project/${projectId}/comment/${commentId}/undelete`,
authentication: token,
withCredentials: true,
method: 'PUT',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(module.exports.setCommentRestored(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(module.exports.setRepliesRestored(commentId));
}
});
});
module.exports.shareProject = (projectId, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('project', module.exports.Status.FETCHING));
api({

View file

@ -0,0 +1,180 @@
const eachLimit = require('async/eachLimit');
const api = require('../lib/api');
const log = require('../lib/log');
const COMMENT_LIMIT = 20;
const {
addNewComment,
resetComments,
Status,
setFetchStatus,
setCommentDeleted,
setCommentReported,
setCommentRestored,
setMoreCommentsToLoad,
setComments,
setError,
setReplies,
setRepliesDeleted,
setRepliesRestored
} = require('../redux/comments.js');
const getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('replies', Status.FETCHING));
const fetchedReplies = {};
eachLimit(commentIds, 10, (parentId, callback) => {
api({
uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`,
authentication: token ? token : null,
params: {offset: offset || 0, limit: COMMENT_LIMIT}
}, (err, body, res) => {
if (err) {
return callback(`Error fetching comment replies: ${err}`);
}
if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound
return callback('No comment reply information');
}
fetchedReplies[parentId] = body;
callback(null, body);
});
}, err => {
if (err) {
dispatch(setFetchStatus('replies', Status.ERROR));
dispatch(setError(err));
return;
}
dispatch(setFetchStatus('replies', Status.FETCHED));
dispatch(setReplies(fetchedReplies));
});
});
const getTopLevelComments = (id, offset, ownerUsername, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('comments', Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${id}/comments`,
authentication: token ? token : null,
params: {offset: offset || 0, limit: COMMENT_LIMIT}
}, (err, body, res) => {
if (err) {
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError(err));
return;
}
if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError('No comment info'));
return;
}
dispatch(setFetchStatus('comments', Status.FETCHED));
dispatch(setComments(body));
dispatch(getReplies(id, body.map(comment => comment.id), 0, ownerUsername, isAdmin, token));
// If we loaded a full page of comments, assume there are more to load.
// This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require
// any more server query complexity, so seems worth it. In the case of a project with
// number of comments divisible by the COMMENT_LIMIT, the load more button will be
// clickable, but upon clicking it will go away.
dispatch(setMoreCommentsToLoad(body.length === COMMENT_LIMIT));
});
});
const getCommentById = (projectId, commentId, ownerUsername, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('comments', Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${commentId}`,
authentication: token ? token : null
}, (err, body, res) => {
if (err) {
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError(err));
return;
}
if (!body || res.statusCode >= 400) { // NotFound
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError('No comment info'));
return;
}
if (body.parent_id) {
// If the comment is a reply, load the parent
return dispatch(getCommentById(projectId, body.parent_id, ownerUsername, isAdmin, token));
}
// If the comment is not a reply, show it as top level and load replies
dispatch(setFetchStatus('comments', Status.FETCHED));
dispatch(setComments([body]));
dispatch(getReplies(projectId, [body.id], 0, ownerUsername, isAdmin, token));
});
});
const deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
/* TODO fetching/fetched/error states updates for comment deleting */
api({
uri: `/proxy/comments/project/${projectId}/comment/${commentId}`,
authentication: token,
withCredentials: true,
method: 'DELETE',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(setCommentDeleted(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(setRepliesDeleted(commentId));
}
});
});
const reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/project/${projectId}/comment/${commentId}/report`,
authentication: token,
withCredentials: true,
method: 'POST',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
// TODO use the reportId in the response for unreporting functionality
dispatch(setCommentReported(commentId, topLevelCommentId));
});
});
const restoreComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/admin/project/${projectId}/comment/${commentId}/undelete`,
authentication: token,
withCredentials: true,
method: 'PUT',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(setCommentRestored(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(setRepliesRestored(commentId));
}
});
});
module.exports = {
getTopLevelComments,
getCommentById,
getReplies,
deleteComment,
reportComment,
restoreComment,
// Re-export these specific action creators directly so the implementer
// does not need to go to two places for comment actions
addNewComment,
resetComments
};

View file

@ -0,0 +1,180 @@
const eachLimit = require('async/eachLimit');
const api = require('../lib/api');
const log = require('../lib/log');
const COMMENT_LIMIT = 20;
const {
addNewComment,
resetComments,
Status,
setFetchStatus,
setCommentDeleted,
setCommentReported,
setCommentRestored,
setMoreCommentsToLoad,
setComments,
setError,
setReplies,
setRepliesDeleted,
setRepliesRestored
} = require('../redux/comments.js');
const getReplies = (studioId, commentIds, offset, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('replies', Status.FETCHING));
const fetchedReplies = {};
eachLimit(commentIds, 10, (parentId, callback) => {
api({
uri: `${isAdmin ? '/admin' : ''}/studios/${studioId}/comments/${parentId}/replies`,
authentication: token ? token : null,
params: {offset: offset || 0, limit: COMMENT_LIMIT}
}, (err, body, res) => {
if (err) {
return callback(`Error fetching comment replies: ${err}`);
}
if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound
return callback('No comment reply information');
}
fetchedReplies[parentId] = body;
callback(null, body);
});
}, err => {
if (err) {
dispatch(setFetchStatus('replies', Status.ERROR));
dispatch(setError(err));
return;
}
dispatch(setFetchStatus('replies', Status.FETCHED));
dispatch(setReplies(fetchedReplies));
});
});
const getTopLevelComments = (id, offset, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('comments', Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : ''}/studios/${id}/comments`,
authentication: token ? token : null,
params: {offset: offset || 0, limit: COMMENT_LIMIT}
}, (err, body, res) => {
if (err) {
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError(err));
return;
}
if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError('No comment info'));
return;
}
dispatch(setFetchStatus('comments', Status.FETCHED));
dispatch(setComments(body));
dispatch(getReplies(id, body.map(comment => comment.id), 0, isAdmin, token));
// If we loaded a full page of comments, assume there are more to load.
// This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require
// any more server query complexity, so seems worth it. In the case of a project with
// number of comments divisible by the COMMENT_LIMIT, the load more button will be
// clickable, but upon clicking it will go away.
dispatch(setMoreCommentsToLoad(body.length === COMMENT_LIMIT));
});
});
const getCommentById = (studioId, commentId, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('comments', Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : ''}/studios/${studioId}/comments/${commentId}`,
authentication: token ? token : null
}, (err, body, res) => {
if (err) {
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError(err));
return;
}
if (!body || res.statusCode >= 400) { // NotFound
dispatch(setFetchStatus('comments', Status.ERROR));
dispatch(setError('No comment info'));
return;
}
if (body.parent_id) {
// If the comment is a reply, load the parent
return dispatch(getCommentById(studioId, body.parent_id, isAdmin, token));
}
// If the comment is not a reply, show it as top level and load replies
dispatch(setFetchStatus('comments', Status.FETCHED));
dispatch(setComments([body]));
dispatch(getReplies(studioId, [body.id], 0, isAdmin, token));
});
});
const deleteComment = (studioId, commentId, topLevelCommentId, token) => (dispatch => {
/* TODO fetching/fetched/error states updates for comment deleting */
api({
uri: `/proxy/comments/studio/${studioId}/comment/${commentId}`,
authentication: token,
withCredentials: true,
method: 'DELETE',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(setCommentDeleted(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(setRepliesDeleted(commentId));
}
});
});
const reportComment = (studioId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/studio/${studioId}/comment/${commentId}/report`,
authentication: token,
withCredentials: true,
method: 'POST',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
// TODO use the reportId in the response for unreporting functionality
dispatch(setCommentReported(commentId, topLevelCommentId));
});
});
const restoreComment = (studioId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/admin/studio/${studioId}/comment/${commentId}/undelete`,
authentication: token,
withCredentials: true,
method: 'PUT',
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
log.error(err || res.body);
return;
}
dispatch(setCommentRestored(commentId, topLevelCommentId));
if (!topLevelCommentId) {
dispatch(setRepliesRestored(commentId));
}
});
});
module.exports = {
getTopLevelComments,
getCommentById,
getReplies,
deleteComment,
reportComment,
restoreComment,
// Re-export these specific action creators directly so the implementer
// does not need to go to two places for comment actions
addNewComment,
resetComments
};

View file

@ -4,12 +4,5 @@
"pattern": "^/components/?$",
"view": "components/components",
"title": "Components"
},
{
"name": "studio",
"pattern": "^/studios-playground/\\d+(/projects|/curators|/activity|/comments)?/?(\\?.*)?$",
"routeAlias": "/studios-playground/?$",
"view": "studio/studio",
"title": "Studio Playground"
}
]

View file

@ -296,6 +296,13 @@
"view": "studentregistration/studentregistration",
"title": "Class Registration"
},
{
"name": "studio",
"pattern": "^/studios-playground/\\d+(/projects|/curators|/activity|/comments)?/?(\\?.*)?$",
"routeAlias": "/studios-playground/?$",
"view": "studio/studio",
"title": "Studio Playground"
},
{
"name": "teacher-faq",
"pattern": "^/educators/faq/?$",

View file

@ -5,22 +5,81 @@ const Page = require('../../components/page/www/page.jsx');
const Box = require('../../components/box/box.jsx');
const Button = require('../../components/forms/button.jsx');
const Carousel = require('../../components/carousel/carousel.jsx');
const Form = require('../../components/forms/form.jsx');
const Input = require('../../components/forms/input.jsx');
const Spinner = require('../../components/spinner/spinner.jsx');
const Grid = require('../../components/grid/grid.jsx');
const TextArea = require('../../components/forms/textarea.jsx');
const SubNavigation = require('../../components/subnavigation/subnavigation.jsx');
const Select = require('../../components/forms/select.jsx');
require('./components.scss');
const Components = () => (
<div className="components">
<div className="inner">
<h1>Nav Bubbles</h1>
<div className="subnavigation">
<SubNavigation>
<a href="">
<li className="active">
cats
</li>
</a>
<a href="">
<li>
also cats
</li>
</a>
<a href="">
<li>
not cats
</li>
</a>
</SubNavigation>
</div>
<h1>Grid</h1>
<Grid
showAvatar
/>
<h1>Button</h1>
<Button>I love button</Button>
<Button>I love buttons</Button>
<h1>Form</h1>
<div className="form">
<Form>
<Select
label="Drop-down"
required
options={[
{
label: 'first option',
value: 1
},
{
label: 'second option',
value: 2
},
{
label: 'third option',
value: 3
}
]}
name="name"
value={1}
/>
<Input
label="Text input"
required
maxLength="30"
name="test"
type="text"
/>
<TextArea
label="Text area"
name="textarea1"
required
/>
</Form>
</div>
<h1>Box Component</h1>
<Box
more="Cat Gifs"

View file

@ -5,6 +5,19 @@
margin: 0 0 10px 0;
}
.subnavigation {
li {
background-color: $active-gray;
&.active {
background-color: $ui-blue;
}
}
}
.form {
width: 200px;
}
.colors {
span {
display: inline-block;

View file

@ -51,13 +51,6 @@ const ConferenceSplash = () => (
value={new Date(2021, 6, 22)}
year="numeric"
/>
{' - '}
<FormattedDate
day="2-digit"
month="long"
value={new Date(2021, 6, 24)}
year="numeric"
/>
</td>
</tr>
<tr className="conf2020-panel-row">

View file

@ -110,7 +110,7 @@ class Comment extends React.Component {
highlighted,
id,
parentId,
projectId,
postURI,
replyUsername,
visibility
} = this.props;
@ -234,7 +234,7 @@ class Comment extends React.Component {
isReply
commenteeId={author.id}
parentId={parentId || id}
projectId={projectId}
postURI={postURI}
onAddComment={this.handlePostReply}
onCancel={this.handleToggleReplying}
/>
@ -285,7 +285,7 @@ Comment.propTypes = {
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
postURI: PropTypes.string,
replyUsername: PropTypes.string,
visibility: PropTypes.string
};

View file

@ -79,7 +79,7 @@ class ComposeComment extends React.Component {
handlePost () {
this.setState({status: ComposeStatus.SUBMITTING});
api({
uri: `/proxy/comments/project/${this.props.projectId}`,
uri: this.props.postURI,
authentication: this.props.user.token,
withCredentials: true,
method: 'POST',
@ -224,42 +224,43 @@ class ComposeComment extends React.Component {
MuteModal.steps.MUTE_INFO : MuteModal.steps.COMMENT_ISSUE;
}
getMuteMessageInfo () {
getMuteMessageInfo (justMuted) {
// return the ids for the messages that are shown for this mute type
// If mute modals have more than one unique "step" we could pass an array of steps
const messageInfo = {
pii: {
name: 'pii',
commentType: 'comment.type.pii',
commentTypePast: 'comment.type.pii.past',
commentType: justMuted ? 'comment.type.pii' : 'comment.type.pii.past',
muteStepHeader: 'comment.pii.header',
muteStepContent: ['comment.pii.content1', 'comment.pii.content2', 'comment.pii.content3']
},
unconstructive: {
name: 'unconstructive',
commentType: 'comment.type.unconstructive',
commentTypePast: 'comment.type.unconstructive.past',
commentType: justMuted ? 'comment.type.unconstructive' : 'comment.type.unconstructive.past',
muteStepHeader: 'comment.unconstructive.header',
muteStepContent: ['comment.unconstructive.content1', 'comment.unconstructive.content2']
muteStepContent: [
justMuted ? 'comment.unconstructive.content1' : 'comment.type.unconstructive.past',
'comment.unconstructive.content2'
]
},
vulgarity: {
name: 'vulgarity',
commentType: 'comment.type.vulgarity',
commentTypePast: 'comment.type.vulgarity.past',
commentType: justMuted ? 'comment.type.vulgarity' : 'comment.type.vulgarity.past',
muteStepHeader: 'comment.vulgarity.header',
muteStepContent: ['comment.vulgarity.content1', 'comment.vulgarity.content2']
muteStepContent: [
justMuted ? 'comment.vulgarity.content1' : 'comment.type.vulgarity.past',
'comment.vulgarity.content2'
]
},
spam: {
name: 'spam',
commentType: 'comment.type.spam',
commentTypePast: 'comment.type.spam.past',
commentType: justMuted ? 'comment.type.spam' : 'comment.type.spam.past',
muteStepHeader: 'comment.spam.header',
muteStepContent: ['comment.spam.content1', 'comment.spam.content2']
},
general: {
name: 'general',
commentType: 'comment.type.general',
commentTypePast: 'comment.type.general.past',
commentType: justMuted ? 'comment.type.general' : 'comment.type.general.past',
muteStepHeader: 'comment.general.header',
muteStepContent: ['comment.general.content1']
}
@ -292,9 +293,9 @@ class ComposeComment extends React.Component {
<p>
<FormattedMessage
id={
this.state.status === ComposeStatus.REJECTED_MUTE ?
this.getMuteMessageInfo().commentType :
this.getMuteMessageInfo().commentTypePast
this.getMuteMessageInfo(
this.state.status === ComposeStatus.REJECTED_MUTE
).commentType
}
/>
</p>
@ -404,7 +405,7 @@ class ComposeComment extends React.Component {
useStandardSizes
className="mod-mute"
commentContent={this.state.message}
muteModalMessages={this.getMuteMessageInfo()}
muteModalMessages={this.getMuteMessageInfo(this.state.status === ComposeStatus.REJECTED_MUTE)}
shouldCloseOnOverlayClick={false}
showFeedback={
this.state.status === ComposeStatus.REJECTED_MUTE
@ -433,7 +434,7 @@ ComposeComment.propTypes = {
onAddComment: PropTypes.func,
onCancel: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
postURI: PropTypes.string,
user: PropTypes.shape({
id: PropTypes.number,
username: PropTypes.string,

View file

@ -87,7 +87,7 @@ class TopLevelComment extends React.Component {
onReport,
onRestore,
replies,
projectId,
postURI,
visibility
} = this.props;
@ -97,7 +97,7 @@ class TopLevelComment extends React.Component {
<FlexRow className="comment-container">
<Comment
highlighted={highlightedCommentId === id}
projectId={projectId}
postURI={postURI}
onAddComment={this.handleAddComment}
{...{
author,
@ -138,7 +138,7 @@ class TopLevelComment extends React.Component {
id={reply.id}
key={reply.id}
parentId={id}
projectId={projectId}
postURI={postURI}
replyUsername={this.authorUsername(reply.commentee_id)}
visibility={reply.visibility}
onAddComment={this.handleAddComment}
@ -188,7 +188,7 @@ TopLevelComment.propTypes = {
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
postURI: PropTypes.string,
replies: PropTypes.arrayOf(PropTypes.object),
visibility: PropTypes.string
};

View file

@ -581,7 +581,7 @@ const PreviewPresentation = ({
{projectInfo.comments_allowed ? (
isLoggedIn ? (
isShared && <ComposeComment
projectId={projectId}
postURI={`/proxy/comments/project/${projectId}`}
onAddComment={onAddComment}
/>
) : (
@ -613,7 +613,7 @@ const PreviewPresentation = ({
key={comment.id}
moreRepliesToLoad={comment.moreRepliesToLoad}
parentId={comment.parent_id}
projectId={projectId}
postURI={`/proxy/comments/project/${projectId}`}
replies={replies && replies[comment.id] ? replies[comment.id] : []}
visibility={comment.visibility}
onAddComment={onAddComment}

View file

@ -5,6 +5,7 @@ const Page = require('../../components/page/www/page.jsx');
const render = require('../../lib/render.jsx');
const previewActions = require('../../redux/preview.js');
const commentActions = require('../../redux/comments.js');
const isSupportedBrowser = require('../../lib/supported-browser').default;
const UnsupportedBrowser = require('./unsupported-browser.jsx');
@ -16,6 +17,7 @@ if (isSupportedBrowser()) {
document.getElementById('app'),
{
preview: previewActions.previewReducer,
comments: commentActions.commentsReducer,
...ProjectView.guiReducers
},
{

View file

@ -29,6 +29,7 @@ const Meta = require('./meta.jsx');
const sessionActions = require('../../redux/session.js');
const navigationActions = require('../../redux/navigation.js');
const previewActions = require('../../redux/preview.js');
const projectCommentActions = require('../../redux/project-comment-actions.js');
const frameless = require('../../lib/frameless');
@ -998,7 +999,7 @@ const mapStateToProps = state => {
canShare: userOwnsProject && state.permissions.social,
canToggleComments: userOwnsProject || isAdmin,
canUseBackpack: isLoggedIn,
comments: state.preview.comments,
comments: state.comments.comments,
enableCommunity: projectInfoPresent,
faved: state.preview.faved,
favedLoaded: state.preview.status.faved === previewActions.Status.FETCHED,
@ -1013,7 +1014,7 @@ const mapStateToProps = state => {
isShared: isShared,
loved: state.preview.loved,
lovedLoaded: state.preview.status.loved === previewActions.Status.FETCHED,
moreCommentsToLoad: state.preview.moreCommentsToLoad,
moreCommentsToLoad: state.comments.moreCommentsToLoad,
original: state.preview.original,
parent: state.preview.parent,
playerMode: state.scratchGui.mode.isPlayerOnly,
@ -1022,7 +1023,7 @@ const mapStateToProps = state => {
projectStudios: state.preview.projectStudios,
registrationOpen: state.navigation.registrationOpen,
remixes: state.preview.remixes,
replies: state.preview.replies,
replies: state.comments.replies,
sessionStatus: state.session.status, // check if used
useScratch3Registration: state.navigation.useScratch3Registration,
user: state.session.session.user,
@ -1034,16 +1035,16 @@ const mapStateToProps = state => {
const mapDispatchToProps = dispatch => ({
handleAddComment: (comment, topLevelCommentId) => {
dispatch(previewActions.addNewComment(comment, topLevelCommentId));
dispatch(projectCommentActions.addNewComment(comment, topLevelCommentId));
},
handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => {
dispatch(previewActions.deleteComment(projectId, commentId, topLevelCommentId, token));
dispatch(projectCommentActions.deleteComment(projectId, commentId, topLevelCommentId, token));
},
handleReportComment: (projectId, commentId, topLevelCommentId, token) => {
dispatch(previewActions.reportComment(projectId, commentId, topLevelCommentId, token));
dispatch(projectCommentActions.reportComment(projectId, commentId, topLevelCommentId, token));
},
handleRestoreComment: (projectId, commentId, topLevelCommentId, token) => {
dispatch(previewActions.restoreComment(projectId, commentId, topLevelCommentId, token));
dispatch(projectCommentActions.restoreComment(projectId, commentId, topLevelCommentId, token));
},
handleOpenRegistration: event => {
event.preventDefault();
@ -1061,8 +1062,8 @@ const mapDispatchToProps = dispatch => ({
dispatch(navigationActions.toggleLoginOpen());
},
handleSeeAllComments: (id, ownerUsername, isAdmin, token) => {
dispatch(previewActions.resetComments());
dispatch(previewActions.getTopLevelComments(id, 0, ownerUsername, isAdmin, token));
dispatch(projectCommentActions.resetComments());
dispatch(projectCommentActions.getTopLevelComments(id, 0, ownerUsername, isAdmin, token));
},
handleUpdateProjectThumbnail: (id, blob) => {
dispatch(previewActions.updateProjectThumbnail(id, blob));
@ -1093,13 +1094,13 @@ const mapDispatchToProps = dispatch => ({
}
},
getTopLevelComments: (id, offset, ownerUsername, isAdmin, token) => {
dispatch(previewActions.getTopLevelComments(id, offset, ownerUsername, isAdmin, token));
dispatch(projectCommentActions.getTopLevelComments(id, offset, ownerUsername, isAdmin, token));
},
getCommentById: (projectId, commentId, ownerUsername, isAdmin, token) => {
dispatch(previewActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token));
dispatch(projectCommentActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token));
},
getMoreReplies: (projectId, commentId, offset, ownerUsername, isAdmin, token) => {
dispatch(previewActions.getReplies(projectId, [commentId], offset, ownerUsername, isAdmin, token));
dispatch(projectCommentActions.getReplies(projectId, [commentId], offset, ownerUsername, isAdmin, token));
},
getFavedStatus: (id, username, token) => {
dispatch(previewActions.getFavedStatus(id, username, token));
@ -1136,7 +1137,7 @@ const mapDispatchToProps = dispatch => ({
},
remixProject: () => {
dispatch(GUI.remixProject());
dispatch(previewActions.resetComments());
dispatch(projectCommentActions.resetComments());
},
setPlayer: player => {
dispatch(GUI.setPlayer(player));

View file

@ -4,7 +4,7 @@
"onePointFour.introNote": "{noteLabel} You can still share projects from 1.4 to the Scratch website. However, projects created in newer versions of Scratch cannot be opened in 1.4.",
"onePointFour.downloads": "Downloads",
"onePointFour.macTitle": "Mac OS X",
"onePointFour.macBody": "Compatible with Mac OSX 10.4 or later",
"onePointFour.macBody": "Compatible with Mac OSX 10.4 through 10.14",
"onePointFour.windowsTitle": "Windows",
"onePointFour.windowsBody": "Compatible with Windows 2000, XP, Vista, 7, and 8",
"onePointFour.windowsNetworkInstaller": "installer",

View file

@ -1,15 +1,92 @@
import React from 'react';
import React, {useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
const StudioComments = () => {
import Button from '../../components/forms/button.jsx';
import ComposeComment from '../preview/comment/compose-comment.jsx';
import TopLevelComment from '../preview/comment/top-level-comment.jsx';
import studioCommentActions from '../../redux/studio-comment-actions.js';
const StudioComments = ({
comments,
getTopLevelComments,
handleNewComment,
moreCommentsToLoad,
replies,
shouldShowCommentComposer
}) => {
const {studioId} = useParams();
const handleLoadComments = useCallback(() => {
getTopLevelComments(studioId, comments.length);
}, [studioId, comments.length]);
useEffect(() => {
if (comments.length === 0) getTopLevelComments(studioId, 0);
}, [studioId]);
return (
<div>
<h2>Comments</h2>
<p>Studio {studioId}</p>
<div>
{shouldShowCommentComposer &&
<ComposeComment
postURI={`/proxy/comments/studio/${studioId}`}
onAddComment={handleNewComment}
/>
}
{comments.map(comment => (
<TopLevelComment
author={comment.author}
canReply={shouldShowCommentComposer}
content={comment.content}
datetimeCreated={comment.datetime_created}
id={comment.id}
key={comment.id}
moreRepliesToLoad={comment.moreRepliesToLoad}
parentId={comment.parent_id}
postURI={`/proxy/comments/studio/${studioId}`}
replies={replies && replies[comment.id] ? replies[comment.id] : []}
visibility={comment.visibility}
onAddComment={handleNewComment}
/>
))}
{moreCommentsToLoad &&
<Button
className="button load-more-button"
onClick={handleLoadComments}
>
<FormattedMessage id="general.loadMore" />
</Button>
}
</div>
</div>
);
};
export default StudioComments;
StudioComments.propTypes = {
comments: PropTypes.arrayOf(PropTypes.shape({})),
getTopLevelComments: PropTypes.func,
handleNewComment: PropTypes.func,
moreCommentsToLoad: PropTypes.bool,
replies: PropTypes.shape({}),
shouldShowCommentComposer: PropTypes.bool
};
export default connect(
state => ({
comments: state.comments.comments,
moreCommentsToLoad: state.comments.moreCommentsToLoad,
replies: state.comments.replies,
// TODO permissions like this to a selector for testing
shouldShowCommentComposer: !!state.session.session.user // is logged in
}),
{
getTopLevelComments: studioCommentActions.getTopLevelComments,
handleNewComment: studioCommentActions.addNewComment
}
)(StudioComments);

View file

@ -25,6 +25,7 @@ import {
} from './lib/redux-modules';
const {studioReducer} = require('../../redux/studio');
const {commentsReducer} = require('../../redux/comments');
const StudioShell = () => {
const match = useRouteMatch();
@ -75,6 +76,7 @@ render(
[curators.key]: curators.reducer,
[managers.key]: managers.reducer,
[activity.key]: activity.reducer,
studio: studioReducer
studio: studioReducer,
comments: commentsReducer
}
);

View file

@ -0,0 +1,151 @@
const tap = require('tap');
const Comments = require('../../../src/redux/comments');
const initialState = Comments.getInitialState();
const reducer = Comments.commentsReducer;
let state;
tap.tearDown(() => process.nextTick(process.exit));
tap.test('Reducer', t => {
t.type(reducer, 'function');
t.type(initialState, 'object');
// Reducers should return their default state when called without state
let undefinedState;
t.deepEqual(initialState, reducer(undefinedState, {type: 'fake action'}));
t.end();
});
tap.test('setFetchStatus', t => {
// initial value
t.equal(initialState.status.comments, Comments.Status.NOT_FETCHED);
state = reducer(initialState, Comments.setFetchStatus('comments', Comments.Status.FETCHING));
t.equal(state.status.comments, Comments.Status.FETCHING);
state = reducer(state, Comments.setFetchStatus('comments', Comments.Status.FETCHED));
t.equal(state.status.comments, Comments.Status.FETCHED);
t.end();
});
tap.test('setComments', t => {
// Initial value
t.deepEqual(initialState.comments, []);
state = reducer(initialState, Comments.setComments([{id: 1}, {id: 2}]));
state = reducer(state, Comments.setComments([{id: 3}, {id: 4}]));
t.deepEqual(state.comments, [{id: 1}, {id: 2}, {id: 3}, {id: 4}]);
t.end();
});
const commentState = {
comments: [
{id: 'id1', visibility: 'visible'},
{id: 'id2', visibility: 'visible'},
{id: 'id3', visibility: 'visible'}
],
replies: {
id1: [
{id: 'id4', visibility: 'visible'},
{id: 'id5', visibility: 'visible'}
]
}
};
tap.test('setComments, discards duplicates', t => {
state = reducer(commentState, Comments.setComments([{id: 'id1'}]));
// Does not increase the number of comments, still 3
t.equal(state.comments.length, 3);
t.end();
});
tap.test('setCommentDeleted, top level comment', t => {
state = reducer(commentState, Comments.setCommentDeleted('id2'));
t.equal(state.comments[1].visibility, 'deleted');
t.end();
});
tap.test('setCommentDeleted, reply comment', t => {
state = reducer(commentState, Comments.setCommentDeleted('id4', 'id1'));
t.equal(state.replies.id1[0].visibility, 'deleted');
t.end();
});
tap.test('setRepliesDeleted/Restored', t => {
state = reducer(commentState, Comments.setRepliesDeleted('id1'));
t.equal(state.replies.id1[0].visibility, 'deleted');
t.equal(state.replies.id1[1].visibility, 'deleted');
state = reducer(state, Comments.setRepliesRestored('id1'));
t.equal(state.replies.id1[0].visibility, 'visible');
t.equal(state.replies.id1[1].visibility, 'visible');
t.end();
});
tap.test('setCommentReported, top level comment', t => {
state = reducer(commentState, Comments.setCommentReported('id2'));
t.equal(state.comments[1].visibility, 'reported');
t.end();
});
tap.test('setCommentReported, reply comment', t => {
state = reducer(commentState, Comments.setCommentReported('id4', 'id1'));
t.equal(state.replies.id1[0].visibility, 'reported');
t.end();
});
tap.test('addNewComment, top level comment', t => {
state = reducer(commentState, Comments.addNewComment({id: 'new comment'}));
// Adds comment to beginning of list
t.equal(state.comments[0].id, 'new comment');
t.end();
});
tap.test('addNewComment, reply comment', t => {
state = reducer(commentState, Comments.addNewComment({id: 'new comment'}, 'id1'));
// Adds replies to the end of the replies list
t.equal(state.replies.id1[2].id, 'new comment');
t.end();
});
tap.test('setReplies', t => {
// setReplies should append new replies
state = reducer(commentState, Comments.setReplies({
id1: {id: 'id6'}
}));
t.equal(state.replies.id1[2].id, 'id6');
t.equal(state.comments[0].moreRepliesToLoad, false);
// setReplies should ignore duplicates, do the same as above again
t.equal(state.replies.id1.length, 3);
state = reducer(state, Comments.setReplies({id1: {id: 'id6'}}));
t.equal(state.replies.id1.length, 3);
// setReplies can add replies to a comment that didn't have any
state = reducer(state, Comments.setReplies({
id2: {id: 'id7'}
}));
t.equal(state.replies.id1.length, 3);
t.equal(state.replies.id2.length, 1);
t.equal(state.replies.id2[0].id, 'id7');
t.equal(state.comments[0].moreRepliesToLoad, false);
t.equal(state.comments[1].moreRepliesToLoad, false);
// Getting 20 (COMMENT_LIMIT) replies sets moreRepliesToLoad to true
state = reducer(state, Comments.setReplies({
id3: (new Array(20)).map((_, i) => ({id: `id${i + 1}`}))
}));
t.equal(state.comments[0].moreRepliesToLoad, false);
t.equal(state.comments[1].moreRepliesToLoad, false);
t.equal(state.comments[2].moreRepliesToLoad, true);
// Getting one more reply sets moreRepliesToLoad back to false
state = reducer(state, Comments.setReplies({
id3: {id: 'id21'}
}));
t.equal(state.comments[2].moreRepliesToLoad, false);
t.end();
});

View file

@ -54,123 +54,3 @@ tap.test('updateProjectInfo', t => {
});
t.end();
});
tap.test('setComments', t => {
// Initial value
t.deepEqual(initialState.comments, []);
state = reducer(initialState, Preview.setComments([{id: 1}, {id: 2}]));
state = reducer(state, Preview.setComments([{id: 3}, {id: 4}]));
t.deepEqual(state.comments, [{id: 1}, {id: 2}, {id: 3}, {id: 4}]);
t.end();
});
const commentState = {
comments: [
{id: 'id1', visibility: 'visible'},
{id: 'id2', visibility: 'visible'},
{id: 'id3', visibility: 'visible'}
],
replies: {
id1: [
{id: 'id4', visibility: 'visible'},
{id: 'id5', visibility: 'visible'}
]
}
};
tap.test('setComments, discards duplicates', t => {
state = reducer(commentState, Preview.setComments([{id: 'id1'}]));
// Does not increase the number of comments, still 3
t.equal(state.comments.length, 3);
t.end();
});
tap.test('setCommentDeleted, top level comment', t => {
state = reducer(commentState, Preview.setCommentDeleted('id2'));
t.equal(state.comments[1].visibility, 'deleted');
t.end();
});
tap.test('setCommentDeleted, reply comment', t => {
state = reducer(commentState, Preview.setCommentDeleted('id4', 'id1'));
t.equal(state.replies.id1[0].visibility, 'deleted');
t.end();
});
tap.test('setRepliesDeleted/Restored', t => {
state = reducer(commentState, Preview.setRepliesDeleted('id1'));
t.equal(state.replies.id1[0].visibility, 'deleted');
t.equal(state.replies.id1[1].visibility, 'deleted');
state = reducer(state, Preview.setRepliesRestored('id1'));
t.equal(state.replies.id1[0].visibility, 'visible');
t.equal(state.replies.id1[1].visibility, 'visible');
t.end();
});
tap.test('setCommentReported, top level comment', t => {
state = reducer(commentState, Preview.setCommentReported('id2'));
t.equal(state.comments[1].visibility, 'reported');
t.end();
});
tap.test('setCommentReported, reply comment', t => {
state = reducer(commentState, Preview.setCommentReported('id4', 'id1'));
t.equal(state.replies.id1[0].visibility, 'reported');
t.end();
});
tap.test('addNewComment, top level comment', t => {
state = reducer(commentState, Preview.addNewComment({id: 'new comment'}));
// Adds comment to beginning of list
t.equal(state.comments[0].id, 'new comment');
t.end();
});
tap.test('addNewComment, reply comment', t => {
state = reducer(commentState, Preview.addNewComment({id: 'new comment'}, 'id1'));
// Adds replies to the end of the replies list
t.equal(state.replies.id1[2].id, 'new comment');
t.end();
});
tap.test('setReplies', t => {
// setReplies should append new replies
state = reducer(commentState, Preview.setReplies({
id1: {id: 'id6'}
}));
t.equal(state.replies.id1[2].id, 'id6');
t.equal(state.comments[0].moreRepliesToLoad, false);
// setReplies should ignore duplicates, do the same as above again
t.equal(state.replies.id1.length, 3);
state = reducer(state, Preview.setReplies({id1: {id: 'id6'}}));
t.equal(state.replies.id1.length, 3);
// setReplies can add replies to a comment that didn't have any
state = reducer(state, Preview.setReplies({
id2: {id: 'id7'}
}));
t.equal(state.replies.id1.length, 3);
t.equal(state.replies.id2.length, 1);
t.equal(state.replies.id2[0].id, 'id7');
t.equal(state.comments[0].moreRepliesToLoad, false);
t.equal(state.comments[1].moreRepliesToLoad, false);
// Getting 20 (COMMENT_LIMIT) replies sets moreRepliesToLoad to true
state = reducer(state, Preview.setReplies({
id3: (new Array(20)).map((_, i) => ({id: `id${i + 1}`}))
}));
t.equal(state.comments[0].moreRepliesToLoad, false);
t.equal(state.comments[1].moreRepliesToLoad, false);
t.equal(state.comments[2].moreRepliesToLoad, true);
// Getting one more reply sets moreRepliesToLoad back to false
state = reducer(state, Preview.setReplies({
id3: {id: 'id21'}
}));
t.equal(state.comments[2].moreRepliesToLoad, false);
t.end();
});

View file

@ -555,20 +555,53 @@ describe('Compose Comment test', () => {
global.Date.now = realDateNow;
});
test('getMuteMessageInfo: muteType set', () => {
test('getMuteMessageInfo: muteType set and just got muted', () => {
const justMuted = true;
const commentInstance = getComposeCommentWrapper({}).instance();
commentInstance.setState({muteType: 'unconstructive'});
expect(commentInstance.getMuteMessageInfo().commentType).toBe('comment.type.unconstructive');
expect(commentInstance.getMuteMessageInfo(justMuted).commentType).toBe('comment.type.unconstructive');
expect(commentInstance.getMuteMessageInfo(justMuted)
.muteStepContent[0]).toBe('comment.unconstructive.content1');
});
test('getMuteMessageInfo: muteType not set', () => {
test('getMuteMessageInfo: muteType set and already muted', () => {
const justMuted = false;
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.getMuteMessageInfo().commentType).toBe('comment.type.general');
commentInstance.setState({muteType: 'pii'});
expect(commentInstance.getMuteMessageInfo(justMuted).commentType).toBe('comment.type.pii.past');
// PII has the same content1 regardless of whether you were just muted
expect(commentInstance.getMuteMessageInfo(justMuted).muteStepContent[0]).toBe('comment.pii.content1');
commentInstance.setState({muteType: 'vulgarity'});
expect(commentInstance.getMuteMessageInfo(justMuted).commentType).toBe('comment.type.vulgarity.past');
expect(commentInstance.getMuteMessageInfo(justMuted).muteStepContent[0]).toBe('comment.type.vulgarity.past');
});
test('getMuteMessageInfo: muteType set to something we don\'t have messages for', () => {
test('getMuteMessageInfo: muteType not set and just got muted', () => {
const justMuted = true;
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.getMuteMessageInfo(justMuted).commentType).toBe('comment.type.general');
// general has the same content1 regardless of whether you were just muted
expect(commentInstance.getMuteMessageInfo(justMuted).muteStepContent[0]).toBe('comment.general.content1');
});
test('getMuteMessageInfo: muteType not set and already muted', () => {
const justMuted = false;
const commentInstance = getComposeCommentWrapper({}).instance();
expect(commentInstance.getMuteMessageInfo(justMuted).commentType).toBe('comment.type.general.past');
});
test('getMuteMessageInfo: muteType set to something we don\'t have messages for and just got muted', () => {
const justMuted = true;
const commentInstance = getComposeCommentWrapper({}).instance();
commentInstance.setState({muteType: 'spaghetti'});
expect(commentInstance.getMuteMessageInfo().commentType).toBe('comment.type.general');
expect(commentInstance.getMuteMessageInfo(justMuted).commentType).toBe('comment.type.general');
});
test('getMuteMessageInfo: muteType set to something we don\'t have messages for and already muted', () => {
const justMuted = false;
const commentInstance = getComposeCommentWrapper({}).instance();
commentInstance.setState({muteType: 'spaghetti'});
expect(commentInstance.getMuteMessageInfo(justMuted).commentType).toBe('comment.type.general.past');
});
});