Files
dndbeyond_src/ddb_main/tools/js/CharacterSheet/containers/CharacterSheet/CharacterSheet.tsx
T

689 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import clsx from "clsx";
import { throttle } from "lodash";
import { createRef, PureComponent } from "react";
import { connect, DispatchProp } from "react-redux";
import { useSearchParams } from "react-router-dom";
import { AnyAction } from "redux";
import {
characterEnvSelectors,
Constants,
FormatUtils,
rulesEngineSelectors,
CharacterTheme,
CharacterPreferences,
characterSelectors,
CharacterStatusSlug,
CampaignDataContract,
} from "@dndbeyond/character-rules-engine/es";
import { Link } from "~/components/Link";
import { NotificationSystem } from "~/components/NotificationSystem";
import { PremadeCharacterEditStatus } from "~/components/PremadeCharacterEditStatus";
import { usePositioning } from "~/hooks/usePositioning";
import { CharacterSheetGuidedTour } from "~/subApps/sheet/components/CharacterSheetGuidedTour";
import { ClaimPremadeButton } from "~/subApps/sheet/components/ClaimPremadeButton";
import { MobileNav } from "~/subApps/sheet/components/MobileNav";
import { useHeadContext } from "../../../../../contexts/Head";
import { ThemeManagerProvider } from "../../../../../contexts/ThemeManager";
import { getCharacterSlots } from "../../../../../helpers/characterServiceApi";
import SynchronousBlocker from "../../../CharacterBuilder/containers/SynchronousBlocker";
import { appEnvActions, toastMessageActions } from "../../../Shared/actions";
import LoadingBlocker from "../../../Shared/components/LoadingBlocker";
import AppErrorTypeEnum from "../../../Shared/constants/AppErrorTypeEnum";
import UserRoles from "../../../Shared/constants/UserRoles";
import { DiceContainer } from "../../../Shared/containers/DiceContainer";
import {
appEnvSelectors,
appInfoSelectors,
toastMessageSelectors,
} from "../../../Shared/selectors";
import {
AppEnvDimensionsState,
AppInfoErrorState,
DiceFeatureConfigurationState,
GameLogState,
ToastMessageState,
} from "../../../Shared/stores/typings";
import config from "../../../config";
import { sheetActions } from "../../actions";
import { InvalidCharacter } from "../../components/InvalidCharacter";
import {
DESKTOP_COMPONENT_START_WIDTH,
MQ_IS_MOBILE_SIZE,
TABLET_COMPONENT_START_WIDTH,
} from "../../config";
import { sheetAppSelectors, sheetSelectors } from "../../selectors";
import { SheetAppState, SheetPositioningInfo } from "../../typings";
import CharacterSheetDesktop from "../CharacterSheetDesktop";
import CharacterSheetMobile from "../CharacterSheetMobile";
import CharacterSheetTablet from "../CharacterSheetTablet";
import styles from "./styles.module.css";
// TODO: We are typing with AnyAction here to work around issues with UnknownAction, the type that was introduced in Redux 5.
// If we decide to modernize to Redux 5, we will need to refactor much of the Rules Engine redux usage.
interface Props extends DispatchProp<AnyAction> {
sheetPosition: SheetPositioningInfo;
initFailed: boolean;
loadingStatus: Constants.CharacterLoadingStatusEnum;
toastMessages: ToastMessageState;
builderUrl: string;
isMobile: boolean;
envDimensions: AppEnvDimensionsState;
isReadonly: boolean;
canEdit: boolean;
nearTopLimit: number;
appError: AppInfoErrorState | null;
isCharacterSheetReady: boolean;
diceEnabled: boolean;
characterId: number | null;
authEndpoint: string;
diceFeatureConfiguration: DiceFeatureConfigurationState;
gameLog: GameLogState;
theme: CharacterTheme;
username: string;
characterName: string | null;
preferences: CharacterPreferences;
setManager: (manager: any) => void;
setTitle: (title: string) => void;
isVttView: boolean;
characterStatus: CharacterStatusSlug | null;
userRoles: UserRoles;
userId: number;
campaign: CampaignDataContract | null;
}
interface State {
curseHeaderHeight: number;
scrollTop: number;
isNotificationOpen: boolean;
isLoaded: boolean;
isSlotLimitsLoaded: boolean;
}
class CharacterSheet extends PureComponent<Props, State> {
static defaultProps = {
nearTopLimit: 40,
characterJson: null,
ruleDataJson: null,
diceEnabled: false,
};
// useImperativeHandle is hard to type when we us our own context this will just disapear
notificationSystem = createRef<any>();
sheetRef = createRef<HTMLDivElement>();
mobileMql: MediaQueryList | null = null;
constructor(props) {
super(props);
this.state = {
curseHeaderHeight: 0,
scrollTop: 0,
isNotificationOpen: false,
isLoaded: false,
isSlotLimitsLoaded: false,
};
}
componentDidMount() {
const { dispatch } = this.props;
const { isLoaded } = this.state;
if (!isLoaded) {
dispatch(sheetActions.characterInit());
this.setState({ isLoaded: true });
}
this.initScrollWatchers();
this.mobileMql = window.matchMedia(MQ_IS_MOBILE_SIZE);
dispatch(appEnvActions.mobileSet(this.mobileMql.matches));
this.mobileMql.onchange = (e) => {
dispatch(appEnvActions.mobileSet(e.matches));
};
this.initResizeWatchers();
}
componentDidUpdate(prevProps, prevState) {
const {
dispatch,
loadingStatus,
canEdit,
diceEnabled,
characterName,
setTitle,
characterStatus,
username,
userRoles,
userId,
campaign,
} = this.props;
if (loadingStatus === Constants.CharacterLoadingStatusEnum.LOADED) {
if (characterStatus === CharacterStatusSlug.PREMADE) {
dispatch(
appEnvActions.dataSet({
canOverrideReadOnly:
userRoles.includes(UserRoles.ADMIN) ||
userRoles.includes(UserRoles.DEV) ||
userRoles.includes(UserRoles.LOREKEEPER),
})
);
} else {
dispatch(
appEnvActions.dataSet({
isReadonly: !canEdit,
diceEnabled: canEdit ? diceEnabled : false,
isUserDM: Number(userId) === campaign?.dmUserId,
})
);
}
if (characterName) {
setTitle(`${characterName}'s Character Sheet`);
}
if (
username &&
characterStatus === CharacterStatusSlug.PREMADE &&
!this.state.isSlotLimitsLoaded
) {
this.setState({ isSlotLimitsLoaded: true });
getCharacterSlots().then((characterSlots) => {
dispatch(
appEnvActions.dataSet({
characterSlots,
})
);
});
}
}
this.checkForMessages();
}
updateSheetScrollState = (): void => {
const curScrollTop = window.scrollY;
const { scrollTop } = this.state;
const { nearTopLimit } = this.props;
if (curScrollTop === 0) {
document.body.classList.add("ct-character-sheet--top");
document.body.classList.remove("ct-character-sheet--not-top");
} else {
document.body.classList.add("ct-character-sheet--not-top");
document.body.classList.remove("ct-character-sheet--top");
}
if (curScrollTop < nearTopLimit) {
document.body.classList.add("ct-character-sheet--near-top");
document.body.classList.remove("ct-character-sheet--far-top");
} else {
document.body.classList.add("ct-character-sheet--far-top");
document.body.classList.remove("ct-character-sheet--near-top");
}
if (curScrollTop < scrollTop) {
// Going up
document.body.classList.add("ct-character-sheet--going-up");
document.body.classList.remove("ct-character-sheet--going-down");
} else {
// Going down
document.body.classList.add("ct-character-sheet--going-down");
document.body.classList.remove("ct-character-sheet--going-up");
}
this.setState({
scrollTop: curScrollTop,
});
};
// TODO: we should setup a remove listener for this
initScrollWatchers = (): void => {
// Assuming there should be only 1
const curseHeader = document.querySelector<HTMLElement>("header.main");
const curseHeaderHeight: number = curseHeader
? curseHeader.offsetHeight
: 0;
this.setState({ curseHeaderHeight: curseHeaderHeight });
this.updateSheetScrollState();
const SCROLL_THROTTLE_AMOUNT = 70;
document.addEventListener(
"scroll",
throttle(() => {
this.updateSheetScrollState();
}, SCROLL_THROTTLE_AMOUNT)
);
};
updateEnvDimensions = (): void => {
const { dispatch } = this.props;
if (this.sheetRef.current === null) {
return;
}
dispatch(
appEnvActions.dimensionsSet({
window: {
width: window.innerWidth,
height: window.innerHeight,
},
sheet: {
width: this.sheetRef.current.clientWidth,
height: this.sheetRef.current.clientHeight,
},
})
);
};
initResizeWatchers = (): void => {
const { envDimensions } = this.props;
if (this.sheetRef.current === null) {
return;
}
this.updateEnvDimensions();
const RESIZE_THROTTLE_AMOUNT: number = 300;
window.addEventListener(
"resize",
throttle(() => {
this.updateEnvDimensions();
}, RESIZE_THROTTLE_AMOUNT)
);
window.addEventListener(
"orientationchange",
throttle(() => {
let oldSize: number = envDimensions.window.width;
let count: number = 0;
let orientationIntervalId = window.setInterval(() => {
if (oldSize !== envDimensions.window.width || count > 15) {
window.clearInterval(orientationIntervalId);
return;
}
if (oldSize !== window.innerWidth) {
this.updateEnvDimensions();
}
count++;
}, 100);
}, RESIZE_THROTTLE_AMOUNT)
);
};
checkForMessages = (): void => {
if (this.notificationSystem.current === null) {
return;
}
const { toastMessages, dispatch } = this.props;
Object.keys(toastMessages).forEach((messageId) => {
const message = toastMessages[messageId];
const { componentProps, Component, ...metaProps } = message.meta;
let children: React.ReactNode;
if (Component) {
let childrenProps = componentProps || {};
children = <Component {...childrenProps} />;
}
if (this.notificationSystem.current !== null) {
this.notificationSystem.current.addNotification({
title: message.payload.title,
message: message.payload.message,
uid: messageId,
children,
onRemove: () => dispatch(toastMessageActions.removeToast(messageId)),
...metaProps,
});
}
});
};
isSheetErrored = (): boolean => {
const { initFailed, appError } = this.props;
return initFailed || appError !== null;
};
renderCharacterSheet = (): React.ReactNode => {
const { envDimensions, isCharacterSheetReady, builderUrl, isReadonly } =
this.props;
if (!isCharacterSheetReady) {
return (
<InvalidCharacter builderUrl={builderUrl} isReadonly={isReadonly} />
);
}
if (envDimensions.window.width >= DESKTOP_COMPONENT_START_WIDTH) {
return <CharacterSheetDesktop />;
} else if (envDimensions.window.width >= TABLET_COMPONENT_START_WIDTH) {
return <CharacterSheetTablet />;
}
return <CharacterSheetMobile />;
};
renderSyncBlocker = (): React.ReactNode => {
const { initFailed, appError } = this.props;
if (appError !== null) {
return null;
}
if (initFailed) {
return null;
}
return <SynchronousBlocker />;
};
renderInitFailed = (): React.ReactNode => {
return (
<div className="ct-character-sheet__failed">
<div className="ct-character-sheet__failed-content">
<p>
Whoops! We rolled a 1 on our Retrieve Character check. We're heading
into town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</div>
</div>
);
};
renderAppError = (): React.ReactNode => {
const { appError } = this.props;
if (appError === null) {
return null;
}
let contentNode: React.ReactNode;
switch (appError.type) {
case AppErrorTypeEnum.AUTH_MISSING:
let signInUrl = `/sign-in?returnUrl=${encodeURIComponent(
window.location.pathname
)}`;
contentNode = (
<>
<p>
You are no longer signed into D&D Beyond. Go to the{" "}
<Link href={signInUrl}>Sign In page</Link> to continue the
adventure!
</p>
</>
);
break;
case AppErrorTypeEnum.API_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our API check. We're heading into town to
visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.API_DATA_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our API Data check. We're heading into
town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.AUTH_FAIL:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our Authentication check. We're heading
into town to visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
case AppErrorTypeEnum.ACCESS_DENIED:
contentNode = (
<>
<p style={{ margin: 0 }}>You reach for the sheet,</p>
<p style={{ margin: 0 }}>But the sigil reads "Private"</p>
<p style={{ margin: 0 }}>Permission denied.</p>
<p style={{ marginTop: 15 }}>
Ask the owner to set their Character Sheet to Campaign Only or
Public in their character's settings.
</p>
</>
);
break;
case AppErrorTypeEnum.NOT_FOUND:
appError.errorId = null;
contentNode = (
<>
<p>
We live in a world of uncertainty. But certainly, the page you
were looking for isnt here. Perhaps this halfling has stolen it
and hidden it in another place. Try searching for what you were
looking for in another realm.
</p>
</>
);
break;
case AppErrorTypeEnum.GENERIC:
default:
contentNode = (
<>
<p>
Whoops! We rolled a 1 on our check. We're heading into town to
visit the blacksmith for repairs.
</p>
<p>Try again after a Short Rest.</p>
</>
);
break;
}
return (
<div
className={[
"ct-character-sheet__failed",
`ct-character-sheet__failed--${FormatUtils.slugify(appError.type)}`,
].join(" ")}
>
<div className="ct-character-sheet__failed-content">
{contentNode}
{appError.errorId && (
<>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Error Code
</div>
<div className="ct-character-sheet__failed-code-value">
{appError.errorId}
</div>
</div>
<div className="ct-character-sheet__failed-code">
<div className="ct-character-sheet__failed-code-label">
Version
</div>
<div className="ct-character-sheet__failed-code-value">
{config.version}
</div>
</div>
</>
)}
</div>
</div>
);
};
renderContent = (): React.ReactNode => {
const { loadingStatus, initFailed, appError } = this.props;
if (appError !== null) {
return this.renderAppError();
}
if (initFailed) {
return this.renderInitFailed();
}
const isLoaded =
loadingStatus === Constants.CharacterLoadingStatusEnum.LOADED;
return (
<>
{isLoaded && this.renderCharacterSheet()}
<LoadingBlocker isFinished={isLoaded || initFailed} />
</>
);
};
render() {
const {
loadingStatus,
sheetPosition,
envDimensions,
isCharacterSheetReady,
diceEnabled,
characterId,
authEndpoint,
diceFeatureConfiguration,
gameLog,
theme,
isReadonly,
characterStatus,
isVttView,
} = this.props;
let sheetStyles: React.CSSProperties = {};
if (envDimensions.window.width >= DESKTOP_COMPONENT_START_WIDTH) {
sheetStyles = {
transform: `translateX(${sheetPosition.left}px)`,
WebkitTransform: `translateX(${sheetPosition.left}px)`,
};
}
let classNames: Array<string> = ["ct-character-sheet"];
if (this.isSheetErrored()) {
classNames.push("ct-character-sheet--failed");
}
if (!isCharacterSheetReady) {
classNames.push("ct-character-sheet--invalid");
}
if (diceEnabled) {
classNames.push("ct-character-sheet--dice-enabled");
}
if (theme.isDarkMode) {
classNames.push("ct-character-sheet--dark-mode");
}
const everythingIsLoadedWithoutErrors: boolean =
loadingStatus === Constants.CharacterLoadingStatusEnum.LOADED &&
isCharacterSheetReady &&
!this.isSheetErrored();
return (
<CharacterSheetGuidedTour>
<div className={classNames.join(" ")}>
{this.renderSyncBlocker()}
<div
className={clsx([
"ct-character-sheet__inner",
!everythingIsLoadedWithoutErrors && styles.sheetNotReady,
])}
ref={this.sheetRef}
style={sheetStyles}
>
<ThemeManagerProvider
manager={{
lightOrDark: theme.isDarkMode ? "dark" : "light",
primary: theme.themeColor,
}}
>
{everythingIsLoadedWithoutErrors && (
<PremadeCharacterEditStatus
characterStatus={characterStatus}
isReadonly={isReadonly}
/>
)}
{this.renderContent()}
{everythingIsLoadedWithoutErrors && <MobileNav />}
</ThemeManagerProvider>
</div>
<NotificationSystem ref={this.notificationSystem} />
{diceEnabled && !isReadonly && (
<DiceContainer
canShow={everythingIsLoadedWithoutErrors}
isVttView={isVttView}
/>
)}
{everythingIsLoadedWithoutErrors &&
characterStatus === CharacterStatusSlug.PREMADE &&
isReadonly && <ClaimPremadeButton />}
</div>
</CharacterSheetGuidedTour>
);
}
}
const CharacterSheetWrapper = (props) => {
const { setTitle } = useHeadContext();
const { getSheetPositioning } = usePositioning();
const [searchParams] = useSearchParams();
const isVttView = searchParams.get("view") === "vtt";
return (
<CharacterSheet
{...props}
setTitle={setTitle}
sheetPosition={getSheetPositioning()}
isVttView={isVttView}
/>
);
};
function mapStateToProps(state: SheetAppState) {
return {
initFailed: sheetSelectors.getInitFailed(state),
loadingStatus: characterEnvSelectors.getLoadingStatus(state),
toastMessages: toastMessageSelectors.getToastMessages(state),
builderUrl: sheetAppSelectors.getBuilderUrl(state),
isMobile: appEnvSelectors.getIsMobile(state),
envDimensions: appEnvSelectors.getDimensions(state),
isReadonly: appEnvSelectors.getIsReadonly(state),
userRoles: appEnvSelectors.getUserRoles(state),
appError: appInfoSelectors.getError(state),
isCharacterSheetReady: rulesEngineSelectors.isCharacterSheetReady(state),
diceEnabled: appEnvSelectors.getDiceEnabled(state),
characterId: appEnvSelectors.getCharacterId(state),
diceFeatureConfiguration:
appEnvSelectors.getDiceFeatureConfiguration(state),
gameLog: appEnvSelectors.getGameLog(state),
theme: rulesEngineSelectors.getCharacterTheme(state),
canEdit: rulesEngineSelectors.getCanEdit(state),
username: appEnvSelectors.getUsername(state),
characterName: rulesEngineSelectors.getName(state),
preferences: rulesEngineSelectors.getPreferences(state),
characterStatus: characterSelectors.getStatusSlug(state),
userId: appEnvSelectors.getUserId(state),
campaign: characterSelectors.getCampaign(state),
};
}
export default connect(mapStateToProps)(CharacterSheetWrapper);