Repository: maproulette/maproulette3
Pull request #1990 (2023-03-21 14:50:27)

bump to v3.12.0

codecov-commenter: ## [Codecov](https://codecov.io/gh/maproulette/maproulette3/pull/1990?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) Report
> Merging [#1990](https://codecov.io/gh/maproulette/maproulette3/pull/1990?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) (90bf326) into [develop](https://codecov.io/gh/maproulette/maproulette3/commit/b06b8e5dea0447758a607d1b983bde909fa36553?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette) (b06b8e5) will **decrease** coverage by `0.01%`.
> The diff coverage is `n/a`.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette)

```diff
@@             Coverage Diff             @@
##           develop    #1990      +/-   ##
===========================================
- Coverage    24.79%   24.78%   -0.01%     
===========================================
  Files          625      625              
  Lines        21005    21005              
  Branches      6368     6368              
===========================================
- Hits          5208     5207       -1     
- Misses       13257    13258       +1     
  Partials      2540     2540              
```


[see 1 file with indirect coverage changes](https://codecov.io/gh/maproulette/maproulette3/pull/1990/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette)

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=maproulette)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3be3b56..cb951e73 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,31 @@ The format is based on
 This project adheres to
 [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## [v3.12.0] - 2023-4-11
+Release with [maproulette-backend_v4.4.0](https://github.com/maproulette/maproulette-backend/releases/tag/v4.4.0)
+
+### Features
+- Add additional caching to multiple endpoints
+- Ability to clone other people's challenges
+- add italian language and sort locale alphabetically
+- Added Blog tab with associated link to the NavBar
+- Added keyboard shortcut for completeTogetherbutton
+- Add Challenge Editor Review Setting
+- Sent comments
+
+### Fixes
+- fix query param in URL for discoverability page
+- Cloning Challenge #maproulette Hashtag Fix 
+- fix sql to account for commas in c.name (API)
+- check class type in internalCreate before checking task count (API)
+- filter out deleted/undiscoverable parents (API)
+
+### System Updates
+- Added Node Dependency Information
+- corrected link for openstreetMap account in documentation
+- update pre-existing server documentation
+- Fix links to Postman collection (API)
+
 ## [v3.11.9] - 2023-3-1
 Release with [maproulette-backend_v4.3.2](https://github.com/maproulette/maproulette-backend/releases/tag/v4.3.2)
 
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 04cf2f3e..0950c242 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -9,6 +9,8 @@ This is the repository that holds the front-end code. The back-end and API are d
 * [jq](https://stedolan.github.io/jq/)
 * [curl](https://curl.haxx.se/)
 
+Note: When installing Node LTS,  ensure that the version of node installed is <= 17.0.1 & conversely, the version of npm is <= 8.19.3. Otherwise, with higher versions of node, yarn would not be able to recognize Node and run the frontend portion of the codebase.
+
 ### Initial Setup
 
 1. Create a `.env.development.local` file and then look through `.env` at the
@@ -44,7 +46,7 @@ Note that this will create the development build and not the 'production' build.
 1. Install the back-end server using the instructions from the maproulette-backend
    project, if you haven't already
 
-2. Visit your [OpenStreetMap account](https://www.openstreetmap.org) and go
+2. Visit your [OpenStreetMap account](https://master.apis.dev.openstreetmap.org) and go
    to My Settings -> oauth settings -> Register your application and setup a
    new application for development. For the `Main Application URL` and
    `Callback URL` settings, put in `http://127.0.0.1:9000` (assuming your
@@ -87,6 +89,8 @@ a local one you have installed. **Please do not use the production API for devel
    variables:
   ```
   REACT_APP_MAP_ROULETTE_SERVER_URL='https://yourserver.com'
+  REACT_APP_MAP_ROULETTE_SERVER_WEBSOCKET_URL='wss://yourserver.com/ws'
+  REACT_APP_MAP_ROULETTE_SERVER_GRAPHQL_URL='https://yourserver.com/graphql'
   REACT_APP_SERVER_API_KEY='your-api-key-for-that-server'
   ```
 
diff --git a/package.json b/package.json
index f1062898..88f72951 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "maproulette3",
-  "version": "3.11.9",
+  "version": "3.12.0",
   "private": true,
   "dependencies": {
     "@apollo/client": "^3.5.4",
diff --git a/src/App.js b/src/App.js
index cb7c51f7..0e56f130 100644
--- a/src/App.js
+++ b/src/App.js
@@ -19,6 +19,7 @@ import AdminPane from './components/AdminPane/AdminPane'
 import InspectTask from './components/AdminPane/Manage/InspectTask/InspectTask'
 import Review from './pages/Review/Review'
 import Inbox from './pages/Inbox/Inbox'
+import Sent from './pages/Sent/Sent'
 import Teams from './pages/Teams/Teams'
 import Achievements from './pages/Achievements/Achievements'
 import Social from './pages/Social/Social'
@@ -125,6 +126,7 @@ export class App extends Component {
             <CachedRoute exact path='/review/:showType' component={Review} />
             <CachedRoute path='/review' component={Review} />
             <CachedRoute path='/inbox' component={Inbox} />
+            <CachedRoute path='/sent' component={Sent} />
             <CachedRoute path='/teams' component={Teams} />
             <CachedRoute path='/social' component={Social} />
             <CachedRoute path='/activity' component={GlobalActivity} />
diff --git a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js
index 82335f9a..4841a49b 100644
--- a/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js
+++ b/src/components/AdminPane/Manage/ChallengeCard/ChallengeControls.js
@@ -156,7 +156,10 @@ export default class ChallengeControls extends Component {
                 pathname:
                   `/admin/project/${projectId}/` +
                   `challenge/${this.props.challenge.id}/edit`,
-                state: _get(this.props.searchCriteria, "filters"),
+                state: _merge(
+                  { cloneChallenge: false },
+                  _get(this.props.searchCriteria, "filters"),
+                )
               }}
               className={this.props.controlClassName}
             >
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
index 068e29eb..ab8f6900 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/EditChallenge.js
@@ -106,8 +106,17 @@ export class EditChallenge extends Component {
    * Returns true if this challenge's data is being cloned from another
    * challenge.
    */
-  isCloningChallenge = () =>
-    !!_get(this.props, "location.state.cloneChallenge");
+  isCloningChallenge = () => {
+    return !!_get(this.props, "location.state.cloneChallenge");
+  }
+
+  /**
+   * Returns the project Id of which the challenge is being cloned into. Project Id
+   * is passed in from challengeDetail.js
+   */
+  getCloningProjectId = () => {
+    return _get(this.props, "location.state.projectId");
+  }
 
   /**
    * Returns true if all challenge fields should be displayed as a single,
@@ -314,6 +323,7 @@ export class EditChallenge extends Component {
           const nextState = _cloneDeep(this.challengeState);
           if (nextState) {
             nextState.refreshAfterSave = true;
+            nextState.cloneChallenge = false;
           }
 
           this.props.history.push({
@@ -372,6 +382,10 @@ export class EditChallenge extends Component {
       delete challengeData.status;
       delete challengeData.virtualParents;
 
+      if (challengeData.checkinComment.includes('#maproulette')) {
+        challengeData.includeCheckinHashtag = true
+      }
+
       if (_isEmpty(this.state.formData.name)) {
         delete challengeData.name;
       }
@@ -380,8 +394,15 @@ export class EditChallenge extends Component {
         delete challengeData.dataOriginDate;
       }
 
-      if (_isEmpty(this.state.formData.overpassQL)) {
-        delete challengeData.overpassQL;
+      if (_isEmpty(this.state.formData.overpassQL) && !_isEmpty(challengeData.overpassQL)) {
+        challengeData.overpassQL =
+          `/*\nTHIS IS THE QUERY OF THE CHALLENGE YOU CLONED PLEASE ADAPT BEFORE USING TO AVOID CREATING DUPLICATE TASKS\n\n` +
+          challengeData.overpassQL +
+          `\n*/`
+      }
+
+      if (!_isEmpty(challengeData.overpassQL) && challengeData.overpassTargetType === '') {
+	      challengeData.overpassTargetType = 'none';
       }
 
       if (_isEmpty(this.state.formData.remoteGeoJson)) {
@@ -518,7 +539,8 @@ export class EditChallenge extends Component {
 
     // Parent field should just be id, not object.
     if (_isObject(challengeData.parent)) {
-      challengeData.parent = challengeData.parent.id;
+      const cloningProjectId = this.getCloningProjectId();
+      challengeData.parent = cloningProjectId ? cloningProjectId : challengeData.parent.id;
     }
 
     // For new challenges, append the #maproulette hashtag to the changeset comment
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
index 88798f79..a957766e 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Messages.js
@@ -721,6 +721,27 @@ will not be able to make sense of it.
       "a park or a lake.",
   },
 
+  reviewSettingLabel: {
+    id: "Admin.EditChallenge.form.reviewSetting.label",
+    defaultMessage: "Request Review By Default",
+  },
+
+  reviewSettingDescription: {
+    id: "Admin.EditChallenge.form.reviewSetting.description",
+    defaultMessage: 
+    "This will pre-check the users request review box when submitting a task.",
+  },
+
+  reviewSettingNotRequired: {
+    id: "Challenge.reviewSetting.notRequired",
+    defaultMessage: "No",
+  },
+
+  reviewSettingRequested: {
+    id: "Challenge.reviewSetting.requested",
+    defaultMessage: "Yes",
+  },
+
   showLongformTooltip: {
     id: "Admin.EditChallenge.controls.showLongform.tooltip",
     defaultMessage: "Show all fields",
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/EditorSchema.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/EditorSchema.js
index 857db41e..410bab1f 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/EditorSchema.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/EditorSchema.js
@@ -1,9 +1,11 @@
 import _map from 'lodash/map'
+import _values from 'lodash/values'
 import _fromPairs from 'lodash/fromPairs'
 import _startCase from 'lodash/startCase'
 import _isUndefined from 'lodash/isUndefined'
 import _intersection from 'lodash/intersection'
 import idPresets from '../../../../../../preset_categories.json'
+import { ChallengeReviewSetting } from '../../../../../../services/Challenge/ChallengeReviewSetting/ChallengeReviewSetting'
 import messages from '../Messages'
 
 const STEP_ID = "Editor"
@@ -33,6 +35,16 @@ export const jsSchema = (intl) => {
   const schemaFields = {
     "$schema": "http://json-schema.org/draft-07/schema#",
     properties: {
+      reviewSetting: {
+        title: intl.formatMessage(messages.reviewSettingLabel),
+        type: "number",
+        enum: _values(ChallengeReviewSetting),
+        enumNames: [
+          intl.formatMessage(messages.reviewSettingRequested),
+          intl.formatMessage(messages.reviewSettingNotRequired),
+        ],
+        default: ChallengeReviewSetting.notRequired,
+      },
       presets: {
         title: intl.formatMessage(messages.presetsLabel),
         type: "boolean",
@@ -100,8 +112,14 @@ export const uiSchema = (intl, user, challengeData, extraErrors, options={}) =>
   }))
 
   const uiSchemaFields = Object.assign({}, {
-    presets: {
+    reviewSetting: {
       "ui:groupHeader": options.longForm ? intl.formatMessage(messages.editorStepHeader) : undefined,
+      "ui:help": intl.formatMessage(messages.reviewSettingDescription),
+      "ui:collapsed": isGroupCollapsed,
+      "ui:toggleCollapsed": toggleGroupCollapsed,
+      "ui:widget": "radio",
+    },
+    presets: {
       "ui:collapsed": isGroupCollapsed,
       "ui:toggleCollapsed": toggleGroupCollapsed,
       "ui:widget": "radio",
diff --git a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/OSMCommitSchema.js b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/OSMCommitSchema.js
index 2cc61292..3b4e318e 100644
--- a/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/OSMCommitSchema.js
+++ b/src/components/AdminPane/Manage/ManageChallenges/EditChallenge/Schemas/OSMCommitSchema.js
@@ -51,7 +51,7 @@ export const jsSchema = (
         intl.formatMessage(messages.includeCheckinHashtagTrueLabel),
         intl.formatMessage(messages.includeCheckinHashtagFalseLabel),
       ],
-      default: true,
+      default: challengeData.checkinComment === undefined || challengeData.includeCheckinHashtag ? true : false,
     };
   }
 
diff --git a/src/components/ChallengeDetail/ChallengeDetail.js b/src/components/ChallengeDetail/ChallengeDetail.js
index 65392970..8952496d 100644
--- a/src/components/ChallengeDetail/ChallengeDetail.js
+++ b/src/components/ChallengeDetail/ChallengeDetail.js
@@ -5,6 +5,7 @@ import classNames from "classnames";
 import _isObject from "lodash/isObject";
 import _get from "lodash/get";
 import _findIndex from "lodash/findIndex";
+import _merge from "lodash/merge";
 import parse from "date-fns/parse";
 import MapPane from "../EnhancedMap/MapPane/MapPane";
 import TaskClusterMap from "../TaskClusterMap/TaskClusterMap";
@@ -21,17 +22,22 @@ import WithStartChallenge from "../HOCs/WithStartChallenge/WithStartChallenge";
 import WithBrowsedChallenge from "../HOCs/WithBrowsedChallenge/WithBrowsedChallenge";
 import WithClusteredTasks from "../HOCs/WithClusteredTasks/WithClusteredTasks";
 import WithCurrentUser from "../HOCs/WithCurrentUser/WithCurrentUser";
+import WithCurrentChallenge from "../AdminPane/HOCs/WithCurrentChallenge/WithCurrentChallenge";
 import WithChallengeTaskClusters from "../HOCs/WithChallengeTaskClusters/WithChallengeTaskClusters";
 import WithTaskClusterMarkers from "../HOCs/WithTaskClusterMarkers/WithTaskClusterMarkers";
+import WithManageableProjects from "../AdminPane/HOCs/WithManageableProjects/WithManageableProjects";
 import { fromLatLngBounds } from "../../services/MapBounds/MapBounds";
 import { ChallengeCommentsPane } from "./ChallengeCommentsPane";
 import SvgSymbol from "../SvgSymbol/SvgSymbol";
 import FlagModal from "./FlagModal";
+import ProjectPickerModal from "../AdminPane/Manage/ProjectPickerModal/ProjectPickerModal";
 
 const ClusterMap = WithChallengeTaskClusters(
   WithTaskClusterMarkers(TaskClusterMap("challengeDetail"))
 );
 
+const ProjectPicker = WithManageableProjects(ProjectPickerModal);
+
 const FLAG_REPO_NAME = process.env.REACT_APP_GITHUB_ISSUES_API_REPO
 const FLAG_REPO_OWNER = process.env.REACT_APP_GITHUB_ISSUES_API_OWNER
 const FLAG_TOKEN = process.env.REACT_APP_GITHUB_ISSUES_API_TOKEN
@@ -58,6 +64,7 @@ export class ChallengeDetail extends Component {
     displayInputError: false,
     displayCheckboxError: false,
     submittingFlag: false,
+    pickingProject: false,
   };
 
   componentDidMount() {
@@ -131,6 +138,23 @@ export class ChallengeDetail extends Component {
     }
   }
 
+  projectPickerCanceled = () => {
+    this.setState({ pickingProject: false });
+  }
+
+  cloneToProject = (project) => {
+    this.setState({ pickingProject: false })
+    this.props.history.push({
+      pathname:
+        `/admin/project/${project.id}/` +
+        `challenge/${this.props.challenge.id}/clone`,
+      state: _merge(
+        { cloneChallenge: true, projectId: project.id },
+        _get(this.props.searchCriteria, 'filters')
+      ),
+    })
+  }
+
   renderDetailTabs = () => {
     const challenge = this.props.browsedChallenge;
     if (!challenge.isVirtual) {
@@ -168,6 +192,20 @@ export class ChallengeDetail extends Component {
               </Fragment>
             )
           }
+          {
+            _isObject(this.props.user) && challenge.enabled && (
+              <Fragment>
+                <span className="mr-px-3"> | </span>
+                <button
+                  onClick={() => this.setState({ pickingProject: true })}
+                  className="mr-text-green-lighter hover:mr-text-white"
+                >
+                  <FormattedMessage {...messages.cloneChallenge} />
+                </button>
+              </Fragment>
+            )
+          }
+          
         </li>
       )
     }
@@ -416,6 +454,15 @@ export class ChallengeDetail extends Component {
                   />
                 }
 
+                {_isObject(this.props.user) && challenge.enabled && this.state.pickingProject && (
+                  <ProjectPicker
+                    {...this.props}
+                    currentProjectId={challenge.parent.id}
+                    onCancel={this.projectPickerCanceled}
+                    onSelectProject={this.cloneToProject}
+                  />
+                )}
+
                 {challenge.parent && ( // virtual challenges don't have projects
                   <Link
                     className="mr-card-challenge__owner"
@@ -478,6 +525,8 @@ export class ChallengeDetail extends Component {
 
 export default WithCurrentUser(
   WithClusteredTasks(
-    WithStartChallenge(WithBrowsedChallenge(injectIntl(ChallengeDetail)))
+    WithStartChallenge(
+      WithBrowsedChallenge(WithCurrentChallenge(injectIntl(ChallengeDetail)))
+    )
   )
-);
+)
diff --git a/src/components/ChallengeDetail/Messages.js b/src/components/ChallengeDetail/Messages.js
index 074a5860..f2ce75df 100644
--- a/src/components/ChallengeDetail/Messages.js
+++ b/src/components/ChallengeDetail/Messages.js
@@ -127,5 +127,10 @@ export default defineMessages({
   email: {
     id: "ChallengeDetails.controls.email",
     defaultMessage: "Email"
+  },
+
+  cloneChallenge: {
+    id: "ChallengeDetails.controls.clone.challenge",
+    defaultMessage: "Clone Challenge"
   }
 })
diff --git a/src/components/HOCs/WithSearch/WithSearch.js b/src/components/HOCs/WithSearch/WithSearch.js
index bb3cf551..415b23c3 100644
--- a/src/components/HOCs/WithSearch/WithSearch.js
+++ b/src/components/HOCs/WithSearch/WithSearch.js
@@ -144,6 +144,10 @@ export const mapDispatchToProps = (dispatch, ownProps, searchGroup) => ({
     }
   },
 
+  clearSearchDispatch: (searchName) => {
+    dispatch(clearSearch(searchName))
+  },
+
   setSearchSort: (sortCriteria) => {
     const sortBy = _get(sortCriteria, 'sortBy')
     let sort = null
diff --git a/src/components/HOCs/WithSearchRoute/WithSearchRoute.js b/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
index c84e9af3..5e68b31a 100644
--- a/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
+++ b/src/components/HOCs/WithSearchRoute/WithSearchRoute.js
@@ -52,7 +52,7 @@ export const WithSearchRoute = function(WrappedComponent, searchGroup) {
             // Clear any redux values first before setting the route criteria
             const routeValues = this.props.history.location.search
             this.props.clearSearchFilters(false)
-            this.props.clearSearch(searchGroup)
+            this.props.clearSearchDispatch(searchGroup)
             this.props.clearMapBounds(searchGroup)
 
             executeRouteSearch(this.routeCriteria, routeValues)
diff --git a/src/components/Navbar/Messages.js b/src/components/Navbar/Messages.js
index a9e85101..78cca339 100644
--- a/src/components/Navbar/Messages.js
+++ b/src/components/Navbar/Messages.js
@@ -4,6 +4,10 @@ import { defineMessages } from 'react-intl'
  * Internationalized messages for use with Navbar.
  */
 export default defineMessages({
+  blog: {
+    id: 'Navbar.links.blog',
+    defaultMessage: "Blog",
+  },
   dashboard: {
     id: 'Navbar.links.dashboard',
     defaultMessage: "Dashboard",
@@ -24,6 +28,11 @@ export default defineMessages({
     defaultMessage: "Inbox",
   },
 
+  sent: {
+    id: 'Navbar.links.sent',
+    defaultMessage: "Sent",
+  },
+
   review: {
     id: 'Navbar.links.review',
     defaultMessage: "Review",
diff --git a/src/components/Navbar/Navbar.js b/src/components/Navbar/Navbar.js
index 2a6a83b1..97cdf205 100644
--- a/src/components/Navbar/Navbar.js
+++ b/src/components/Navbar/Navbar.js
@@ -231,6 +231,11 @@ export const ProfileMenu = function(props) {
           </div>
         </NavLink>
       </li>
+      <li>
+        <NavLink to="/sent" onClick={props.closeDropdown}>
+          <FormattedMessage {...messages.sent} />
+        </NavLink>
+      </li>
       <li>
         <NavLink to="/review" onClick={props.closeDropdown}>
           <FormattedMessage {...messages.review} />
@@ -323,6 +328,16 @@ const Nav = props => (
         <FormattedMessage {...messages.help} />
       </a>
     </li>
+
+    <li className="mr-flex mr-flex-col mr-justify-center">
+      <a
+        href="https://blog.maproulette.org/"
+        target="_blank"
+        rel="noopener noreferrer"
+        onClick={props.closeMobileMenu}>
+        <FormattedMessage {...messages.blog} />
+      </a>
+    </li>
   </React.Fragment>
 )
 
@@ -387,6 +402,11 @@ const MobileNav = props => (
             </div>
           </NavLink>
         </li>
+        <li>
+          <NavLink to="/sent" onClick={props.closeMobileMenu}>
+            <FormattedMessage {...messages.sent} />
+          </NavLink>
+        </li>
         <li>
           <NavLink to="/review" onClick={props.closeMobileMenu}>
             <FormattedMessage {...messages.review} />
diff --git a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js
index 2cf49735..4c70e4d6 100644
--- a/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js
+++ b/src/components/TaskPane/ActiveTaskDetails/ActiveTaskControls/ActiveTaskControls.js
@@ -56,6 +56,7 @@ export class ActiveTaskControls extends Component {
     tags: null,
     revisionLoadBy: TaskReviewLoadMethod.all,
     doneLoadByFromHistory: false,
+    needsReview: this.props.challenge.reviewSetting === 1 ? true : undefined
   }
 
   setComment = comment => this.setState({comment})
diff --git a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
index 9d767089..08df42d2 100644
--- a/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
+++ b/src/components/Widgets/TaskBundleWidget/TaskBundleWidget.js
@@ -4,6 +4,7 @@ import { Popup } from 'react-leaflet'
 import _get from 'lodash/get'
 import _isFinite from 'lodash/isFinite'
 import _isEqual from 'lodash/isEqual'
+import _isEmpty from 'lodash/isEmpty'
 import _sum from 'lodash/sum'
 import _values from 'lodash/values'
 import _pick from 'lodash/pick'
@@ -41,6 +42,7 @@ import BusySpinner from '../../BusySpinner/BusySpinner'
 import TaskAnalysisTable from '../../TaskAnalysisTable/TaskAnalysisTable'
 import TaskMarkerContent from './TaskMarkerContent'
 import messages from './Messages'
+import WithKeyboardShortcuts from '../../HOCs/WithKeyboardShortcuts/WithKeyboardShortcuts'
 
 const VALID_STATUS_KEYS = [TaskAction.available, TaskAction.skipped, TaskAction.tooHard]
 const VALID_STATUSES =
@@ -68,6 +70,7 @@ const ClusterMap = WithChallengeTaskClusters(
   true
 )
 
+const shortcutGroup = 'taskEditing'
 
 export default class TaskBundleWidget extends Component {
   bundleTasks = () => {
@@ -77,6 +80,22 @@ export default class TaskBundleWidget extends Component {
     this.props.createTaskBundle([...this.props.selectedTasks.selected.keys()])
   }
 
+  handleKeyboardShortcuts = (event) => {
+    // Ignore if shortcut group is not active
+    if (_isEmpty(this.props.activeKeyboardShortcuts[shortcutGroup])) {
+      return
+    }
+
+    if (this.props.textInputActive(event)) { // ignore typing in inputs
+      return
+    }
+
+    const shortcuts = this.props.keyboardShortcutGroups.taskEditing
+    if (event.key === shortcuts.completeTogether.key) {
+      this.bundleTasks()
+    }
+  }
+
   /**
    * Initialize the cluster filters to include tasks from the current challenge
    * and initially within bounds of "nearby" tasks as a starting point for the
@@ -141,6 +160,11 @@ export default class TaskBundleWidget extends Component {
     if (this.props.task && this.props.selectedTasks && !this.props.isTaskSelected(this.props.task.id)) {
       this.props.selectTasks([this.props.task])
     }
+
+    this.props.activateKeyboardShortcut(
+      shortcutGroup,
+      _pick(this.props.keyboardShortcutGroups.taskEditing, 'c'),
+      this.handleKeyboardShortcuts)
   }
 
   componentDidUpdate(prevProps) {
@@ -164,6 +188,9 @@ export default class TaskBundleWidget extends Component {
     if (_isFinite(challengeId)) {
       this.props.unsubscribeFromChallengeTaskMessages(challengeId)
     }
+
+    this.props.deactivateKeyboardShortcut(shortcutGroup, 'c',
+                                          this.handleKeyboardShortcuts)
   }
 
   render() {
@@ -373,7 +400,7 @@ registerWidgetType(
               WithBoundedTasks(
                 WithBrowsedChallenge(
                   WithWebSocketSubscriptions(
-                    TaskBundleWidget,
+                    WithKeyboardShortcuts(TaskBundleWidget )
                   )
                 ),
                 'filteredClusteredTasks',
diff --git a/src/lang/af.json b/src/lang/af.json
index 4effa47b..ae24d2c7 100644
--- a/src/lang/af.json
+++ b/src/lang/af.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/cs_CZ.json b/src/lang/cs_CZ.json
index 53cdb22a..033010cd 100644
--- a/src/lang/cs_CZ.json
+++ b/src/lang/cs_CZ.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/de.json b/src/lang/de.json
index db7647c3..f9fa2bfe 100644
--- a/src/lang/de.json
+++ b/src/lang/de.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persisch - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/en-US.json b/src/lang/en-US.json
index 72d973b1..2c4bbfa0 100644
--- a/src/lang/en-US.json
+++ b/src/lang/en-US.json
@@ -517,6 +517,7 @@
   "ChallengeCard.totalTasks": "Total Tasks",
   "ChallengeDetails.Task.fields.featured.label": "Featured",
   "ChallengeDetails.controls.checkbox.error": "Please ensure that checkbox is checked before continue",
+  "ChallengeDetails.controls.clone.challenge": "Clone Challenge",
   "ChallengeDetails.controls.email": "Email",
   "ChallengeDetails.controls.favorite.label": "Favorite",
   "ChallengeDetails.controls.favorite.tooltip": "Save to favorites",
@@ -1028,6 +1029,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
@@ -1091,6 +1093,7 @@
   "Navbar.links.inbox": "Inbox",
   "Navbar.links.leaderboard": "Leaderboard",
   "Navbar.links.review": "Review",
+  "Navbar.links.sent": "Sent",
   "Navbar.links.signout": "Sign out",
   "Navbar.links.superAdminMetrics": "Super Admin Metrics",
   "Navbar.links.teams": "Teams",
@@ -1311,6 +1314,12 @@
   "SavedTasks.widget.viewTask": "View Task",
   "ScreenTooNarrow.header": "Please widen your browser window",
   "ScreenTooNarrow.message": "This page is not yet compatible with smaller screens. Please expand your browser window or switch to a larger device or display.",
+  "Sent.controls.challenges.label": "Challenges",
+  "Sent.controls.goToChallenge.label": "Go to Challenge",
+  "Sent.controls.goToTask.label": "Go to Task",
+  "Sent.controls.refreshComments.label": "Refresh",
+  "Sent.controls.tasks.label": "Tasks",
+  "Sent.header": "Sent Comments",
   "ShareLink.controls.copy.label": "Copy",
   "SignIn.control.label": "Sign in",
   "SignIn.control.longLabel": "Sign in to participate",
diff --git a/src/lang/es.json b/src/lang/es.json
index 30142778..867299bd 100644
--- a/src/lang/es.json
+++ b/src/lang/es.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persa - Irán)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/fa_IR.json b/src/lang/fa_IR.json
index ba6c78ab..71bdc145 100644
--- a/src/lang/fa_IR.json
+++ b/src/lang/fa_IR.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/fr.json b/src/lang/fr.json
index 8804ac75..67a29f38 100644
--- a/src/lang/fr.json
+++ b/src/lang/fr.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persan - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/it_IT.json b/src/lang/it_IT.json
index fb200dc8..f7a5d600 100644
--- a/src/lang/it_IT.json
+++ b/src/lang/it_IT.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Tedesco)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/ja.json b/src/lang/ja.json
index 375d2cfc..eda19581 100644
--- a/src/lang/ja.json
+++ b/src/lang/ja.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/ko.json b/src/lang/ko.json
index 269181a3..e453d270 100644
--- a/src/lang/ko.json
+++ b/src/lang/ko.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/nl.json b/src/lang/nl.json
index 3bb38d68..0f72821f 100644
--- a/src/lang/nl.json
+++ b/src/lang/nl.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Duits)",
   "Locale.en-US.label": "en-US (U.S. Engels)",
   "Locale.es.label": "es (Spaans)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Perzisch - Iran)",
   "Locale.fr.label": "fr (Frans)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/pl.json b/src/lang/pl.json
index 73cc720d..c724d0f7 100644
--- a/src/lang/pl.json
+++ b/src/lang/pl.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (niemiecki)",
   "Locale.en-US.label": "en-US (angielski - US)",
   "Locale.es.label": "es (hiszpański)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (perski - Iran)",
   "Locale.fr.label": "fr (francuski)",
   "Locale.ja.label": "ja (japoński)",
diff --git a/src/lang/pt_BR.json b/src/lang/pt_BR.json
index 91fc1580..ae631b7d 100644
--- a/src/lang/pt_BR.json
+++ b/src/lang/pt_BR.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/ru_RU.json b/src/lang/ru_RU.json
index 6f47a092..3a8e3022 100644
--- a/src/lang/ru_RU.json
+++ b/src/lang/ru_RU.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "нем (Немецкий)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Испанский)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Французский)",
   "Locale.ja.label": "ja (Японский)",
diff --git a/src/lang/tr.json b/src/lang/tr.json
index aa8aa560..7eae6a18 100644
--- a/src/lang/tr.json
+++ b/src/lang/tr.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Almanca)",
   "Locale.en-US.label": "en-US (ABD İngilizcesi)",
   "Locale.es.label": "es (İspanyolca)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Farsça - İran)",
   "Locale.fr.label": "fr (Fransızca)",
   "Locale.ja.label": "ja (Japonca)",
diff --git a/src/lang/uk.json b/src/lang/uk.json
index c474c285..013dafba 100644
--- a/src/lang/uk.json
+++ b/src/lang/uk.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (فارسی - ایران)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/vi.json b/src/lang/vi.json
index 31aa56b8..66ddfe36 100644
--- a/src/lang/vi.json
+++ b/src/lang/vi.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/lang/zh_TW.json b/src/lang/zh_TW.json
index f3023aa2..86fd1f94 100644
--- a/src/lang/zh_TW.json
+++ b/src/lang/zh_TW.json
@@ -1028,6 +1028,7 @@
   "Locale.de.label": "de (Deutsch)",
   "Locale.en-US.label": "en-US (U.S. English)",
   "Locale.es.label": "es (Español)",
+  "Locale.it.label": "it (Italiano)",
   "Locale.fa-IR.label": "fa-IR (Persian - Iran)",
   "Locale.fr.label": "fr (Français)",
   "Locale.ja.label": "ja (日本語)",
diff --git a/src/pages/Sent/HeaderSent.js b/src/pages/Sent/HeaderSent.js
new file mode 100644
index 00000000..94e2a3bb
--- /dev/null
+++ b/src/pages/Sent/HeaderSent.js
@@ -0,0 +1,56 @@
+import React, { Component } from 'react'
+import { FormattedMessage } from 'react-intl'
+import BusySpinner from '../../components/BusySpinner/BusySpinner'
+import messages from './Messages'
+import CommentType from '../../services/Comment/CommentType'
+
+class HeaderSent extends Component {
+  render() {
+    return (
+      <header className="mr-mb-8 mr-pb-4 mr-border-b mr-border-grey-light">
+        <div className="sm:mr-flex sm:mr-justify-between mr-mb-2 sm:mr-mb-4">
+          <div>
+            <h1 className="mr-h2 mr-text-white mr-mb-2 md:mr-mb-0 md:mr-mr-4">
+              <FormattedMessage {...messages.sentHeader} />
+            </h1>
+          </div>
+          <div>
+            {this.props.notificationsLoading ?
+             <BusySpinner /> :
+             <button
+              className="mr-button mr-button--green-lighter mr-button--small"
+              onClick={() => this.props.refreshData()}
+             >
+               <FormattedMessage {...messages.refreshCommentsLabel} />
+             </button>
+            }
+          </div>
+        </div>
+        <div className="mr-flex mr-justify-end mr-items-center">
+          <ul className="mr-list-reset mr-leading-tight mr-flex mr-items-center mr-text-white">
+            <li className="mr-mr-3 mr-pr-3 mr-border-r mr-border-grey-light">
+              <button
+                onClick={() => this.props.setCommentType(CommentType.TASK)}
+                disabled={this.props.commentType === CommentType.TASK}
+                className={`${this.props.commentType === CommentType.TASK ? "mr-text-green-lighter " : ""}mr-text-current hover:mr-text-green-lighter mr-transition`}
+              >
+                <FormattedMessage {...messages.tasks} />
+              </button>
+            </li>
+            <li>
+              <button
+                onClick={() => this.props.setCommentType(CommentType.CHALLENGE)}
+                disabled={this.props.commentType === CommentType.CHALLENGE}
+                className={`${this.props.commentType === CommentType.CHALLENGE ? "mr-text-green-lighter " : ""}mr-text-current hover:mr-text-green-lighter mr-transition`}
+              >
+                <FormattedMessage {...messages.challenges} />
+              </button>
+            </li>
+          </ul>
+        </div>
+      </header>
+    )
+  }
+}
+
+export default HeaderSent
diff --git a/src/pages/Sent/Messages.js b/src/pages/Sent/Messages.js
new file mode 100644
index 00000000..152f9893
--- /dev/null
+++ b/src/pages/Sent/Messages.js
@@ -0,0 +1,36 @@
+import { defineMessages } from 'react-intl'
+
+/**
+ * Internationalized messages for use with Sent Table
+ */
+export default defineMessages({
+  sentHeader: {
+    id: "Sent.header",
+    defaultMessage: "Sent Comments",
+  },
+
+  refreshCommentsLabel: {
+    id: "Sent.controls.refreshComments.label",
+    defaultMessage: "Refresh",
+  },
+
+  tasks: {
+    id: "Sent.controls.tasks.label",
+    defaultMessage: "Tasks",
+  },
+
+  challenges: {
+    id: "Sent.controls.challenges.label",
+    defaultMessage: "Challenges",
+  },
+
+  goToTaskLabel: {
+    id: "Sent.controls.goToTask.label",
+    defaultMessage: "Go to Task",
+  },
+
+  goToChallengeLabel: {
+    id: "Sent.controls.goToChallenge.label",
+    defaultMessage: "Go to Challenge",
+  }
+})
diff --git a/src/pages/Sent/Notification.js b/src/pages/Sent/Notification.js
new file mode 100644
index 00000000..1ccf3bc9
--- /dev/null
+++ b/src/pages/Sent/Notification.js
@@ -0,0 +1,88 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import { Link } from 'react-router-dom'
+import { FormattedMessage }
+       from 'react-intl'
+import External from '../../components/External/External'
+import Modal from '../../components/Modal/Modal'
+import Markdown from '../../components/MarkdownContent/MarkdownContent'
+import messages from './Messages'
+import CommentType from '../../services/Comment/CommentType'
+
+class Notification extends Component {
+  notificationBody = () => {
+    return <CommentBody id={this.props.id} text={this.props.text} type={this.props.type} />
+  }
+
+  renderedNotification = () => (
+    <div
+      key={this.props.id}
+      className={"mr-bg-pink-light-10 mr-rounded mr-border mr-border-pink-light-50 mr-mt-2"}
+    >
+      <div className="mr-p-6">
+        {this.notificationBody()}
+      </div>
+    </div>
+  )
+
+  render() {
+    return (
+      <External>
+        <Modal
+          isActive={true}
+          onClose={this.props.onClose}
+        >
+          {this.renderedNotification()}
+        </Modal>
+      </External>
+    )
+  }
+}
+
+const CommentBody = function(props) {
+  return (
+    <React.Fragment>
+      <AttachedComment text={props.text} />
+      <ViewTask id={props.id} type={props.type}/>
+    </React.Fragment>
+  )
+}
+
+const AttachedComment = function(props) {
+  return (
+    <React.Fragment>
+      <div className="mr-text-sm mr-rounded-sm mr-p-2 mr-bg-grey-lighter-10">
+        <div className="mr-markdown mr-markdown--longtext">
+          <Markdown allowShortCodes markdown={props.text} />
+        </div>
+      </div>
+    </React.Fragment>
+  )
+}
+
+const ViewTask = function(props) {
+  const path = props.type === CommentType.TASK
+    ? `task/${props.id}`
+    : `browse/challenges/${props.id}?tab=conversation`
+  const label = props.type === CommentType.TASK
+    ? messages.goToTaskLabel
+    : messages.goToChallengeLabel
+
+  return (
+    <div className="mr-mt-8 mr-links-green-lighter">
+      <div className="mr-flex mr-leading-tight">
+        <Link to={path} target="_blank" rel="noopener noreferrer">
+            <FormattedMessage {...label} />
+        </Link>
+      </div>
+    </div>
+  )
+}
+
+Notification.propTypes = {
+  id: PropTypes.number.isRequired,
+  onClose: PropTypes.func.isRequired,
+  text: PropTypes.string.isRequired,
+}
+
+export default Notification
diff --git a/src/pages/Sent/Sent.js b/src/pages/Sent/Sent.js
new file mode 100644
index 00000000..4b586444
--- /dev/null
+++ b/src/pages/Sent/Sent.js
@@ -0,0 +1,148 @@
+import React, { useEffect, useState } from 'react'
+import ReactTable from 'react-table-6'
+import { Link } from 'react-router-dom'
+import { FormattedDate, FormattedTime, injectIntl } from 'react-intl'
+import { intlTableProps } from '../../components/IntlTable/IntlTable'
+import { useSentComments } from './SentCommentsHooks'
+import WithCurrentUser from '../../components/HOCs/WithCurrentUser/WithCurrentUser'
+import HeaderSent from './HeaderSent'
+import Notification from './Notification'
+import CommentType from '../../services/Comment/CommentType'
+
+const defaultSorted = {
+  id: 'created',
+  desc: true,
+}
+
+const defaultPagination = {
+  page: 0,
+  pageSize: 25
+}
+
+const Sent = (props) => {
+  const [commentType, setCommentType] = useState(CommentType.TASK);
+  const comments = useSentComments(commentType);
+  const [sortCriteria, setSortCriteria] = useState(defaultSorted);
+  const [pagination, setPagination] = useState(defaultPagination);
+  const [selectedComment, setSelectedComment] = useState(null);
+
+  useEffect(() => {
+    comments.fetch(props.user?.id, sortCriteria, pagination)
+  }, [props.user?.id, commentType, sortCriteria.id, sortCriteria.desc, pagination.page, pagination.pageSize]);
+
+  const resetTable = () => {
+    setSortCriteria(defaultSorted);
+    setPagination(defaultPagination);
+  };
+
+  return (
+    <div className="mr-bg-gradient-r-green-dark-blue mr-px-6 mr-py-8 md:mr-py-12 mr-flex mr-justify-center mr-items-center">
+      <section className="mr-flex-grow mr-w-full mr-bg-black-15 mr-p-4 md:mr-p-8 mr-rounded">
+        <HeaderSent
+          commentType={commentType}
+          refreshData={() => comments.fetch(props.user?.id, sortCriteria, pagination)}
+          setCommentType={(t) => {
+            setCommentType(t);
+            resetTable();
+          }}
+        />
+        <ReactTable
+          data={comments.data}
+          columns={commentType === CommentType.TASK ? taskColumns({ setSelectedComment }) : challengeColumns({ setSelectedComment })}
+          defaultPageSize={defaultPagination.pageSize}
+          defaultSorted={[defaultSorted]}
+          minRows={1}
+          manual
+          sorted={[sortCriteria]}
+          multiSort={false}
+          noDataText={"no data"}
+          loading={comments.loading}
+          pageSize={pagination.pageSize}
+          pages={Math.ceil(comments.count / pagination.pageSize)}
+          onSortedChange={(criteria) => { setSortCriteria(criteria[0])}}
+          onPageChange={(page) => setPagination({ ...pagination, page })}
+          onPageSizeChange={(pageSize) => setPagination({ ...pagination, pageSize })}
+          page={pagination.page}
+          getTrProps={() => {
+            const styles = {}
+            return { style: styles }
+          }}
+          {...intlTableProps(props.intl)}
+        >
+          {(state, makeTable) => {
+            return makeTable()
+          }}
+        </ReactTable>
+      </section>
+
+      {selectedComment &&
+        <Notification onClose={() => setSelectedComment(null)} id={selectedComment.id} text={selectedComment.text} type={selectedComment.type} />
+      }
+    </div>
+  )
+}
+
+const taskColumns = ({ setSelectedComment }) => [{
+  id: 'task_id',
+  Header: 'Task ID',
+  accessor: 'taskId',
+  Cell: ({value}) => (
+    <Link to={`task/${value}`} target="_blank" rel="noopener noreferrer">{value}</Link>
+  ),
+  maxWidth: 100,
+  sortable: true,
+  resizable: false
+},{
+  id: 'created',
+  Header: 'Date',
+  accessor: 'created',
+  Cell: ({value}) => (
+    <><FormattedDate value={value} /> <FormattedTime value={value} /></>
+  ),
+  maxWidth: 200,
+  sortable: true,
+  resizable: false
+},{
+  id: 'comment',
+  Header: 'Comment',
+  accessor: 'comment',
+  Cell: ({ value, row }) => {
+    return <Link onClick={() => setSelectedComment({ id: row.id, text: value, type: CommentType.TASK })} style={{ textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}>{value}</Link>
+  },
+  sortable: true,
+  resizable: false
+}]
+
+const challengeColumns = ({ setSelectedComment }) => [{
+  id: 'challenge_name',
+  Header: 'Challenge',
+  accessor: 'challengeName',
+  Cell: ({ value, original }) => {
+    return <Link to={`browse/challenges/${original.challengeId}?tab=conversation`} target="_blank" rel="noopener noreferrer">{value}</Link>
+  },
+  maxWidth: 200,
+  sortable: true,
+  resizable: false
+},{
+  id: 'created',
+  Header: 'Date',
+  accessor: 'created',
+  Cell: ({value}) => (
+    <><FormattedDate value={value} /> <FormattedTime value={value} /></>
+  ),
+  maxWidth: 200,
+  sortable: true,
+  resizable: false
+},{
+  id: 'comment',
+  Header: 'Comment',
+  accessor: 'comment',
+  Cell: ({ value, original }) => {
+    return <Link onClick={() => setSelectedComment({ id: original.challengeId, text: value, type: CommentType.CHALLENGE })} style={{ textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}>{value}</Link>
+  },
+  sortable: true,
+  resizable: false
+}]
+
+export default injectIntl(WithCurrentUser(Sent))
+
diff --git a/src/pages/Sent/SentCommentsHooks.js b/src/pages/Sent/SentCommentsHooks.js
new file mode 100644
index 00000000..152df0d5
--- /dev/null
+++ b/src/pages/Sent/SentCommentsHooks.js
@@ -0,0 +1,40 @@
+import { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { fetchUserComments } from '../../services/User/User';
+
+export const useSentComments = (commentType) => {
+  const dispatch = useDispatch();
+  const [data, setData] = useState([]);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState("");
+  const [count, setCount] = useState(0);
+
+  const fetch = async (userId, sort = { id: 'created', desc: true }, pagination = { page: 0, pageSize: 25 }) => {
+    if (userId) {
+      setError("");
+      setLoading(true);
+
+      const apiFilters = {
+        sort: sort.id,
+        order: sort.desc ? "DESC" : "ASC",
+        page: pagination.page,
+        limit: pagination.pageSize
+      }
+
+      const result = await dispatch(fetchUserComments(userId, commentType, apiFilters));
+
+      if (!Array.isArray(result)) {
+        setError("An error occurred while fetching data");
+        setData([]);
+        setCount(0);
+      } else {
+        setData(result);
+        setCount(result?.[0]?.fullCount || 0)
+      }
+  
+      setLoading(false);
+    }
+  }
+
+  return { data, fetch, loading, error, count };
+}
diff --git a/src/services/Challenge/Challenge.js b/src/services/Challenge/Challenge.js
index 54a47abe..15b790c7 100644
--- a/src/services/Challenge/Challenge.js
+++ b/src/services/Challenge/Challenge.js
@@ -1010,6 +1010,7 @@ export const saveChallenge = function (
           "limitReviewTags",
           "taskStyles",
           "requiresLocal",
+          "reviewSetting",
         ]
       );
 
diff --git a/src/services/Challenge/ChallengeReviewSetting/ChallengeReviewSetting.js b/src/services/Challenge/ChallengeReviewSetting/ChallengeReviewSetting.js
new file mode 100644
index 00000000..3ead9251
--- /dev/null
+++ b/src/services/Challenge/ChallengeReviewSetting/ChallengeReviewSetting.js
@@ -0,0 +1,8 @@
+// These constants are defined on the server
+export const REVIEW_SETTING_NOT_REQUIRED = 0
+export const REVIEW_SETTING_REQUESTED = 1
+
+export const ChallengeReviewSetting = Object.freeze({
+  requested: REVIEW_SETTING_REQUESTED,
+  notRequired: REVIEW_SETTING_NOT_REQUIRED,
+})
\ No newline at end of file
diff --git a/src/services/Comment/CommentType.js b/src/services/Comment/CommentType.js
new file mode 100644
index 00000000..dc4a2af8
--- /dev/null
+++ b/src/services/Comment/CommentType.js
@@ -0,0 +1,6 @@
+const CommentType = {
+  TASK: 'TASK',
+  CHALLENGE: 'CHALLENGE'
+}
+
+export default CommentType;
diff --git a/src/services/KeyboardShortcuts/KeyMappings.js b/src/services/KeyboardShortcuts/KeyMappings.js
index a51fd68b..0b54c7b8 100644
--- a/src/services/KeyboardShortcuts/KeyMappings.js
+++ b/src/services/KeyboardShortcuts/KeyMappings.js
@@ -24,6 +24,7 @@ export default {
   taskEditing: {
     cancel: {key: 'Escape', label: messages.cancel, keyLabel: messages.escapeLabel},
     fitBounds: {key: '0', label: messages.fitBounds},
+    completeTogether: {key: 'c', label: messages.completeTogether}
   },
   layers: {
     layerOSMData: {key: 'o', label: messages.layerOSMData},
diff --git a/src/services/KeyboardShortcuts/Messages.js b/src/services/KeyboardShortcuts/Messages.js
index 381f1116..b6d3498d 100644
--- a/src/services/KeyboardShortcuts/Messages.js
+++ b/src/services/KeyboardShortcuts/Messages.js
@@ -59,6 +59,11 @@ export default defineMessages({
     defaultMessage: "Fit Map to Task Features",
   },
 
+  completeTogether: {
+    id: "KeyMapping.taskEditing.completeTogether",
+    defaultMessage: "Complete tasks together"
+  },
+
   escapeLabel: {
     id: "KeyMapping.taskEditing.escapeLabel",
     defaultMessage: "ESC",
diff --git a/src/services/Server/APIRoutes.js b/src/services/Server/APIRoutes.js
index ff7025dd..ae071fe2 100644
--- a/src/services/Server/APIRoutes.js
+++ b/src/services/Server/APIRoutes.js
@@ -163,7 +163,9 @@ const apiRoutes = (factory) => {
       reviewerLeaderboard: factory.get("/data/reviewer/leaderboard"),
       find: factory.get("/users/find/:username"),
       findPreferred: factory.get("/users/find"),
-      all: factory.get("/users")
+      all: factory.get("/users"),
+      taskComments: factory.get("/comments/user/:id", { noCache: true }),
+      challengeComments: factory.get("/challengeComments/user/:id", { noCache: true })
     },
     user: {
       whoami: factory.get("/user/whoami"),
diff --git a/src/services/User/Locale/Locale.js b/src/services/User/Locale/Locale.js
index ea9e58f0..b0919bf8 100644
--- a/src/services/User/Locale/Locale.js
+++ b/src/services/User/Locale/Locale.js
@@ -9,11 +9,12 @@ import messages from "./Messages";
 // `Messages.js` file in this directory
 
 // Supported locales
-export const Locale = Object.freeze({
+export const unsortedLocale = {
   enUS: "en-US",
   es: "es",
   fr: "fr",
   de: "de",
+  it: "it",
   af: "af",
   ja: "ja",
   ko: "ko",
@@ -27,7 +28,15 @@ export const Locale = Object.freeze({
   tr: "tr",
   pl: "pl",
   "zh-TW": "zh-TW"
-});
+};
+
+export const Locale = Object.freeze(Object.keys(unsortedLocale).sort().reduce(
+  (obj, key) => { 
+    obj[key] = unsortedLocale[key]; 
+    return obj;
+  }, 
+  {}
+));
 
 // Dynamic imports to load locale data and translation files
 const LocaleImports = {
@@ -51,6 +60,11 @@ const LocaleImports = {
       import("../../../lang/de.json"),
       import("@formatjs/intl-relativetimeformat/locale-data/de"),
     ]),
+  [Locale.it]: () =>
+    Promise.all([
+      import("../../../lang/it_IT.json"),
+      import("@formatjs/intl-relativetimeformat/locale-data/it"),
+    ]),
   [Locale.af]: () =>
     Promise.all([
       import("../../../lang/af.json"),
diff --git a/src/services/User/Locale/Messages.js b/src/services/User/Locale/Messages.js
index 16cb0575..d54bf9b6 100644
--- a/src/services/User/Locale/Messages.js
+++ b/src/services/User/Locale/Messages.js
@@ -16,6 +16,10 @@ export default defineMessages({
     id: "Locale.de.label",
     defaultMessage: "de (Deutsch)",
   },
+  it: {
+    id: "Locale.it.label",
+    defaultMessage: "it (Italiano)",
+  },
   fr: {
     id: "Locale.fr.label",
     defaultMessage: "fr (Français)",
diff --git a/src/services/User/User.js b/src/services/User/User.js
index 3e29d198..bccebfb5 100644
--- a/src/services/User/User.js
+++ b/src/services/User/User.js
@@ -31,6 +31,7 @@ import { taskSchema,
 import { addError } from '../Error/Error'
 import AppErrors from '../Error/AppErrors'
 import { setupCustomCache } from '../../utils/setupCustomCache'
+import CommentType from '../Comment/CommentType'
 
 // 60 minutes
 const CACHE_TIME = 60 * 60 * 1000;
@@ -268,6 +269,29 @@ export const fetchUser = function(userId) {
   };
 };
 
+/**
+ * Fetch data on all users (up to the given limit).
+ */
+export const fetchUserComments = function (userId, type = CommentType.TASK, filters = { sort: 'created', order: 'DESC', page: 0, limit: 25 } ) {
+  return function(dispatch) {
+    const endpoint = type === CommentType.CHALLENGE ? api.users.challengeComments : api.users.taskComments
+
+    return new Endpoint(endpoint, {
+      variables: { id: userId },
+      params: filters
+    })
+      .execute()
+      .then((normalizedResults) => {
+        return normalizedResults;
+      })
+      .catch((error) => {
+        dispatch(addError(AppErrors.user.fetchFailure))
+        console.log(error.response || error);
+        return error.response || error;
+      });
+  }
+};
+
 /**
  * Fetch the public user data for the given user.
  *


Test patch:
diff --git a/src/components/ChallengeDetail/ChallengeDetail.test.js b/src/components/ChallengeDetail/ChallengeDetail.test.js
index f2d97068..d14eec5e 100644
--- a/src/components/ChallengeDetail/ChallengeDetail.test.js
+++ b/src/components/ChallengeDetail/ChallengeDetail.test.js
@@ -15,4 +15,56 @@ describe("ChallengeDetail", () => {
     const text = getByText("Test Passes");
     expect(text).toBeInTheDocument();
   });
+
+  it('renders public challenge page when user is not logged in', () => {
+    const { queryByText } = global.withProvider(
+      <ChallengeDetail
+        match={{ url: '', params: {} }}
+        history={{ location: { pathname: '', search: '' } }}
+        location={{ search: '' }}
+        browsedChallenge={{
+          id: 1,
+          parent: { id: 2 },
+          lastTaskRefresh: new Date(),
+          dataOriginDate: new Date(),
+        }}
+        intl={{
+          formatMessage: () => '',
+          formatDate: () => '',
+        }}
+      />
+    )
+    const text = queryByText('Sign in to participate')
+    expect(text).toBeInTheDocument()
+    const cloneText = queryByText('Clone Challenge')
+    expect(cloneText).not.toBeInTheDocument()
+  })
+
+  it('renders clone challenge button for discoverable challenge', () => {
+    const { getByText } = global.withProvider(
+      <ChallengeDetail
+        checkingLoginStatus={false}
+        user={{
+          isLoggedIn: true,
+          id: 1,
+        }}
+        match={{ url: '', params: {} }}
+        history={{ location: { pathname: '', search: '' } }}
+        location={{ search: '' }}
+        browsedChallenge={{
+          id: 1,
+          parent: { id: 2 },
+          lastTaskRefresh: new Date(),
+          dataOriginDate: new Date(),
+          enabled: true,
+        }}
+        intl={{
+          formatMessage: () => '',
+          formatDate: () => '',
+        }}
+      />
+    )
+    const cloneText = getByText('Clone Challenge')
+    expect(cloneText).toBeInTheDocument()
+  })
 });
diff --git a/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap b/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
index 48a4a4b6..40ce863a 100644
--- a/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
+++ b/src/components/HOCs/WithSearch/__snapshots__/WithSearch.test.js.snap
@@ -4,6 +4,7 @@ exports[`mapDispatchToProps maps function performSearch 1`] = `
 Object {
   "clearMapBounds": [Function],
   "clearSearch": [Function],
+  "clearSearchDispatch": [Function],
   "clearSearchFilters": [Function],
   "locateMapToUser": [Function],
   "performSearch": [Function],
@@ -24,6 +25,7 @@ exports[`mapDispatchToProps maps function setSearch 1`] = `
 Object {
   "clearMapBounds": [Function],
   "clearSearch": [Function],
+  "clearSearchDispatch": [Function],
   "clearSearchFilters": [Function],
   "locateMapToUser": [Function],
   "performSearch": [Function],
diff --git a/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js b/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js
index 1e1fef81..e2220acf 100644
--- a/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js
+++ b/src/components/HOCs/WithSearchRoute/WithSearchRoute.test.js
@@ -14,7 +14,7 @@ beforeEach(() => {
   basicProps.setSearchFilters = jest.fn()
   basicProps.setSearchSort = jest.fn()
   basicProps.setKeywordFilter = jest.fn()
-  basicProps.clearSearch = jest.fn()
+  basicProps.clearSearchDispatch = jest.fn()
   basicProps.clearSearchFilters = jest.fn()
   basicProps.clearMapBounds = jest.fn()
 })
@@ -28,7 +28,7 @@ test("executeRouteSearch clears old redux values first", () => {
   )
   expect(basicProps.clearSearchFilters).toHaveBeenCalled()
   expect(basicProps.clearMapBounds).toHaveBeenCalled()
-  expect(basicProps.clearSearch).toHaveBeenCalled()
+  expect(basicProps.clearSearchDispatch).toHaveBeenCalled()
 })
 
 test("executeRouteSearch executes setSearch when passed query=", () => {