Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/CongratulateModal/CongratulateModal.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { Component } from "react";
import Confetti from "react-dom-confetti";
import ConfettiModule from "react-dom-confetti";
import { FormattedMessage } from "react-intl";
import Modal from "../Modal/Modal";
import SvgSymbol from "../SvgSymbol/SvgSymbol";
import messages from "./Messages";
import "./CongratulateModal.scss";

// react-dom-confetti is Babel-era CJS: `exports.default = Confetti` alongside an
// `__esModule` marker. Vite's dep optimizer doesn't pick up on that marker and
// emits `export default require_confetti()`, so a default import receives the
// whole module object rather than the component. Unwrap it here; the fallback
// covers bundlers that do honor the marker and hand back the class directly.
const Confetti = ConfettiModule?.default ?? ConfettiModule;

/**
* CongratulateModal presents a celebratory modal that displays a
* congratulatory message along with a confetti cannon visual effect
Expand Down
18 changes: 15 additions & 3 deletions src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
updateTaskTags,
} from "../../../services/Task/Task";
import { TaskLoadMethod } from "../../../services/Task/TaskLoadMethod/TaskLoadMethod";
import { isLockableTask } from "../../../services/Task/TaskLock";
import { fetchTaskForReview } from "../../../services/Task/TaskReview/TaskReview";
import { fetchUser } from "../../../services/User/User";
import { renewVirtualChallenge } from "../../../services/VirtualChallenge/VirtualChallenge";
Expand Down Expand Up @@ -398,9 +399,20 @@ export const nextRandomTask = async (dispatch, props, currentTaskId, taskLoadBy)
* Load and lock a requested next task
*/
export const nextRequestedTask = function (dispatch, props, requestedTaskId) {
return dispatch(fetchTask(requestedTaskId))
.then(() => dispatch(startTask(requestedTaskId)))
.then((normalizedResults) => normalizedResults?.entities?.tasks?.[normalizedResults.result]);
const taskFrom = (normalizedResults) =>
normalizedResults?.entities?.tasks?.[normalizedResults.result];

return dispatch(fetchTask(requestedTaskId)).then((fetched) => {
// Skip the lock for tasks that can't be worked on - the user gets only one
// at a time, and WithLockedTask won't take one for these either
if (!isLockableTask(taskFrom(fetched))) {
return taskFrom(fetched);
}

return dispatch(startTask(requestedTaskId)).then(
(started) => taskFrom(started) ?? taskFrom(fetched),
);
});
};

/**
Expand Down
111 changes: 97 additions & 14 deletions src/components/HOCs/WithLockedTask/WithLockedTask.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import _omit from "lodash/omit";
import { Component } from "react";
import { connect } from "react-redux";
import { bindActionCreators } from "redux";
import { getLockConflict } from "../../../services/Task/LockConflict";
import {
refreshTaskLock,
releaseTask,
requestUnlock,
startTask,
} from "../../../services/Task/Task";
import { isLockableTask } from "../../../services/Task/TaskLock";

// Used for lock storage events. Users will be locked from other task tabs
// if logging out, signing back in, or have multiple tabs on one task
Expand Down Expand Up @@ -36,6 +38,11 @@ const lockStorage = {
* about to work on. If a lock cannot be acquired, the WrappedCompont will be
* passed a `taskReadOnly` flag set to true
*
* Locks are only ever released intentionally: via the lock dialog, the locked
* tasks widget, a lock-conflict release, or a server-side release on task
* completion/skip. Merely navigating away from (or closing) a task keeps the
* lock, leaving the user's work claimed until they release it or it expires.
*
* @author [Kelli Rotstan](https://github.com/krotstan)
*/
const WithLockedTask = function (WrappedComponent) {
Expand All @@ -45,14 +52,37 @@ const WithLockedTask = function (WrappedComponent) {
tryingLock: false,
failureDetails: null,
lockedAt: null,
lockConflict: null,
releasingConflict: false,
lockNotApplicable: false,
};

lockTask = (task) => {
if (!task) {
return Promise.reject("Invalid task");
}

this.setState({ tryingLock: true, failureDetails: null });
if (!isLockableTask(task, this.props.challenge)) {
// Read-only, but not a failure: there's no lock to offer to retry or
// request, so flag it separately from a genuine lock failure
this.setState({
readOnly: true,
tryingLock: false,
failureDetails: null,
lockConflict: null,
lockedAt: null,
lockNotApplicable: true,
});
lockStorage.removeLock(task.id);
return Promise.resolve(false);
}

this.setState({
tryingLock: true,
failureDetails: null,
lockConflict: null,
lockNotApplicable: false,
});
return this.props
.startTask(task.id)
.then(() => {
Expand All @@ -67,26 +97,65 @@ const WithLockedTask = function (WrappedComponent) {
return true;
})
.catch((err) => {
this.setState({ readOnly: true, tryingLock: false, failureDetails: err.details });
this.setState({
readOnly: true,
tryingLock: false,
failureDetails: err.details,
lockConflict: getLockConflict(err),
});
return false;
});
};

/**
* Releases the task the user already holds a lock on elsewhere (per a
* one-lock-per-user 409 conflict), then retries locking the given task.
*
* Exposed as `releaseConflictingTaskLockAndRetry` (and the conflict itself
* as `taskLockConflict`) rather than the plainer names: WithTaskBundle
* tracks its own, separate bundle-lock conflict under `lockConflict` and
* sits inside this HOC, so unnamespaced props would be clobbered by it.
*/
releaseConflictingLockAndRetry = async (task) => {
const conflictTaskId = this.state.lockConflict?.lockedTaskId;
if (!conflictTaskId) {
return false;
}

this.setState({ releasingConflict: true });
try {
await this.props.releaseTask(conflictTaskId);
} catch (error) {
console.warn("Error releasing conflicting lock:", error);
} finally {
this.setState({ releasingConflict: false });
}

return this.lockTask(task);
};

/**
* Explicitly release the lock on the given task. Only ever called in
* response to a deliberate user action - see the class doc above.
*/
unlockTask = (task) => {
if (!task) {
return Promise.reject("Invalid task");
}

this.props
const release = this.props
.releaseTask(task.id)
.then(() => {
//wait for lock to be cleared in db and provide some leeway
//time with setTimeout before triggering storage event
setTimeout(() => lockStorage.removeLock(task.id), 1500);
return true;
})
.catch(() => null);
.catch(() => false);

this.setState({ lockedAt: null });

return release;
};

requestUnlock = (taskId) => {
Expand All @@ -101,11 +170,15 @@ const WithLockedTask = function (WrappedComponent) {
return Promise.reject("Invalid task");
}

if (!isLockableTask(task, this.props.challenge)) {
return Promise.resolve(false);
}

return this.props
.refreshTaskLock(task.id)
.then(() => {
if (this.state.readOnly) {
this.setState({ readOnly: false, failureDetails: null });
this.setState({ readOnly: false, failureDetails: null, lockConflict: null });
}

this.setState({ lockedAt: Date.now() });
Expand All @@ -115,15 +188,22 @@ const WithLockedTask = function (WrappedComponent) {
return true;
})
.catch((err) => {
this.setState({ readOnly: true, failureDetails: err.details });
// A refresh can hit the same one-lock-per-user conflict an initial
// lock can (e.g. the user grabbed a lock elsewhere in the meantime),
// so surface it the same way and let the conflict dialog handle it
this.setState({
readOnly: true,
failureDetails: err.details,
lockConflict: getLockConflict(err),
});
return false;
});
};

syncLocks = () => {
const { task } = this.props;

if (task) {
if (task && isLockableTask(task, this.props.challenge)) {
if (!lockStorage.isLocked(task.id)) {
this.refreshTaskLock(task);
}
Expand All @@ -142,10 +222,10 @@ const WithLockedTask = function (WrappedComponent) {

componentDidUpdate(prevProps) {
if (prevProps?.task?.id !== this.props.task?.id) {
if (prevProps.task) {
this.unlockTask(prevProps.task);
}

// The lock on the previous task is deliberately left in place - releasing
// a lock is always an explicit user action. If the previous lock is still
// held, locking this task reports a lock conflict and the wrapped
// component can offer to release it (releaseConflictingLockAndRetry).
if (this.props.task) {
this.lockTask(this.props.task);
}
Expand All @@ -156,10 +236,9 @@ const WithLockedTask = function (WrappedComponent) {
const { task } = this.props;
window.removeEventListener("storage", this.syncLocks);

// Only the local tab bookkeeping is cleared here; the lock itself stays
// until the user releases it (or it expires server-side).
if (task) {
if (localStorage.getItem("isLoggedIn")) {
this.unlockTask(task);
}
lockStorage.removeLock(task.id);
}
}
Expand All @@ -176,6 +255,10 @@ const WithLockedTask = function (WrappedComponent) {
unlockTask={this.unlockTask}
refreshTaskLock={this.refreshTaskLock}
requestUnlock={this.requestUnlock}
taskLockNotApplicable={this.state.lockNotApplicable}
taskLockConflict={this.state.lockConflict}
releasingTaskLockConflict={this.state.releasingConflict}
releaseConflictingTaskLockAndRetry={this.releaseConflictingLockAndRetry}
/>
);
}
Expand Down
Loading
Loading