digitransit-ui/app/component/itinerary/navigator/NaviCardContainer.js

281 lines
7.6 KiB
JavaScript

import { matchShape, routerShape } from 'found';
import PropTypes from 'prop-types';
import React, { useEffect, useRef, useState } from 'react';
import { intlShape } from 'react-intl';
import { addAnalyticsEvent } from '../../../util/analyticsUtils';
import { isAnyLegPropertyIdentical, legTime } from '../../../util/legUtils';
import { configShape, legShape } from '../../../util/shapes';
import { getTopics, updateClient } from '../ItineraryPageUtils';
import NaviCard from './NaviCard';
import NaviStack from './NaviStack';
import {
getAdditionalMessages,
getItineraryAlerts,
getTransitLegState,
itinerarySearchPath,
LEGTYPE,
} from './NaviUtils';
import usePrevious from './hooks/usePrevious';
import { usePushNotification } from './hooks/usePushNotification';
const HIDE_TOPCARD_DURATION = 2000; // milliseconds
const getLegType = (leg, firstLeg, time, interlineWithPreviousLeg) => {
let legType;
if (time < legTime(firstLeg.start)) {
if (!firstLeg.forceStart) {
legType = LEGTYPE.PENDING;
} else {
legType = LEGTYPE.WAIT;
}
} else if (leg) {
legType = leg.transitLeg ? LEGTYPE.TRANSIT : LEGTYPE.MOVE;
} else {
legType = interlineWithPreviousLeg ? LEGTYPE.WAIT_IN_VEHICLE : LEGTYPE.WAIT;
}
return legType;
};
function NaviCardContainer(
{
focusToLeg,
time,
legs,
position,
tailLength,
currentLeg,
nextLeg,
firstLeg,
lastLeg,
previousLeg,
isJourneyCompleted,
containerTopPosition,
settings,
},
context,
) {
// All notifications including those user has dismissed.
const [messages, setMessages] = useState(new Map());
// notifications that are shown to the user.
const [activeMessages, setActiveMessages] = useState([]);
const [legChanging, setLegChanging] = useState(false);
const { isEqual: legChanged } = usePrevious(currentLeg, (prev, current) =>
isAnyLegPropertyIdentical(prev, current, ['legId', 'mode']),
);
const focusRef = useRef(false);
const { intl, config, match, router } = context;
const { createNotification, notificationConsent } =
usePushNotification(config);
const handleRemove = index => {
const msg = messages.get(activeMessages[index].id);
msg.closed = true; // remember closing action
setActiveMessages(activeMessages.filter((_, i) => i !== index));
};
function addMessages(incomingMessages, newMessages) {
newMessages.forEach(m => {
if (!messages.get(m.id)) {
createNotification(m.title, m.body);
}
incomingMessages.set(m.id, m);
});
}
// track only relevant vehicles for the journey.
// addd 20 s buffer so that vehicle location is available
// for leg validation long enough
const getNaviTopics = () =>
getTopics(
legs.filter(leg => legTime(leg.end) >= time - 20000),
config,
);
const makeNewItinerarySearch = () => {
const path = itinerarySearchPath(
time,
currentLeg,
nextLeg,
position,
match.params.to,
);
router.push(path);
};
useEffect(() => {
addAnalyticsEvent({
category: 'Itinerary',
event: 'navigator',
action: 'start_navigation',
});
updateClient(getNaviTopics(), context);
}, []);
useEffect(() => {
const incomingMessages = new Map();
// Alerts for NaviStack
addMessages(
incomingMessages,
getItineraryAlerts(
legs,
time,
position,
tailLength,
intl,
messages,
makeNewItinerarySearch,
config,
settings,
),
);
if (nextLeg?.transitLeg) {
// Messages for NaviStack.
addMessages(incomingMessages, [
...getTransitLegState(nextLeg, intl, messages, time, settings),
...getAdditionalMessages(
currentLeg,
nextLeg,
firstLeg,
time,
config,
messages,
intl,
legs,
),
]);
}
let timeoutId;
if (legChanged) {
updateClient(getNaviTopics(), context);
setLegChanging(true);
timeoutId = setTimeout(() => {
setLegChanging(false);
}, HIDE_TOPCARD_DURATION);
if (currentLeg) {
focusToLeg?.(currentLeg);
}
}
// Update messages if there are changes
const expired = activeMessages.find(m => m.expiresOn < time);
if (incomingMessages.size || expired) {
// Current active messages. Filter away expired messages.
const previousValidMessages = expired
? activeMessages.filter(m => !m.expiresOn || m.expiresOn > time)
: activeMessages;
// handle messages that are updated.
const keptMessages = previousValidMessages.filter(
msg => !incomingMessages.get(msg.id),
);
const newMessages = Array.from(incomingMessages.values());
setActiveMessages([...keptMessages, ...newMessages]);
setMessages(new Map([...messages, ...incomingMessages]));
}
if (!focusRef.current && focusToLeg) {
// handle initial focus when not tracking
if (currentLeg) {
focusToLeg(currentLeg);
} else if (time < legTime(firstLeg.start)) {
focusToLeg(firstLeg);
} else {
focusToLeg(nextLeg || lastLeg);
}
focusRef.current = true;
}
return () => clearTimeout(timeoutId);
}, [time, firstLeg]);
// LegChange fires animation, we need to keep the old data until card goes out of the view.
const l = legChanging ? previousLeg : currentLeg;
const legType = getLegType(
l,
firstLeg,
time,
nextLeg?.interlineWithPreviousLeg,
);
let className;
if (isJourneyCompleted || legChanging) {
className = 'hide-card';
} else {
className = 'show-card';
}
return (
// TODO Create proper button for asking notification permissions.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={`navi-card-container ${className}`}
style={{ top: containerTopPosition }}
aria-live={legChanging ? undefined : 'polite'}
aria-hidden={legChanging ? 'true' : 'false'}
onClick={() => notificationConsent()}
>
<NaviCard
leg={l}
nextLeg={nextLeg}
legType={legType}
time={time}
position={position}
tailLength={tailLength}
cardAnimation={className}
/>
{activeMessages.length > 0 && (
<NaviStack
messages={activeMessages}
handleRemove={handleRemove}
cardAnimation={className}
/>
)}
</div>
);
}
NaviCardContainer.propTypes = {
focusToLeg: PropTypes.func,
time: PropTypes.number.isRequired,
legs: PropTypes.arrayOf(legShape).isRequired,
position: PropTypes.shape({
lat: PropTypes.number,
lon: PropTypes.number,
status: PropTypes.string,
locationCount: PropTypes.number,
watchId: PropTypes.number,
}),
tailLength: PropTypes.number.isRequired,
containerTopPosition: PropTypes.number.isRequired,
currentLeg: legShape,
nextLeg: legShape,
firstLeg: legShape,
lastLeg: legShape,
previousLeg: legShape,
isJourneyCompleted: PropTypes.bool,
// eslint-disable-next-line
settings: PropTypes.object.isRequired,
};
NaviCardContainer.defaultProps = {
focusToLeg: undefined,
position: undefined,
currentLeg: undefined,
nextLeg: undefined,
firstLeg: undefined,
lastLeg: undefined,
previousLeg: undefined,
isJourneyCompleted: false,
};
NaviCardContainer.contextTypes = {
intl: intlShape.isRequired,
config: configShape.isRequired,
match: matchShape.isRequired,
router: routerShape.isRequired,
executeAction: PropTypes.func.isRequired,
getStore: PropTypes.func.isRequired,
};
export default NaviCardContainer;