Files
Desktop/ts/state/ducks/nav.std.ts
2026-02-24 03:48:13 +10:00

362 lines
8.8 KiB
TypeScript

// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEqual } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
import type { ThunkAction } from 'redux-thunk';
import { createLogger } from '../../logging/log.std.js';
import { useBoundActions } from '../../hooks/useBoundActions.std.js';
import { beforeNavigateService } from '../../services/BeforeNavigate.std.js';
import { NavTab, SettingsPage } from '../../types/Nav.std.js';
import {
getActivePanel,
getPanels,
getSelectedLocation,
} from '../selectors/nav.std.js';
import type { PanelArgsType } from '../../types/Panels.std.js';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js';
import type { StateType as RootStateType } from '../reducer.preload.js';
import type { Location, PanelInfo } from '../../types/Nav.std.js';
const log = createLogger('nav');
// Types
function printLocation(location: Location): string {
if (location.tab === NavTab.Settings) {
if (location.details.page === SettingsPage.Profile) {
return `${location.tab}/${location.details.page}/${location.details.state}`;
}
return `${location.tab}/${location.details.page}`;
}
return `${location.tab}`;
}
function getDefaultPanels(): PanelInfo {
return {
isAnimating: false,
wasAnimated: false,
direction: undefined,
stack: [],
watermark: -1,
};
}
// State
export type NavStateType = ReadonlyDeep<{
selectedLocation: Location;
lastChatTabLocation?: Location;
}>;
// Actions
export const CHANGE_LOCATION = 'nav/CHANGE_LOCATION';
const PANEL_ANIMATION_DONE = 'nav/PANEL_ANIMATION_DONE';
const PANEL_ANIMATION_STARTED = 'nav/PANEL_ANIMATION_STARTED';
export type ChangeLocationAction = ReadonlyDeep<{
type: typeof CHANGE_LOCATION;
payload: { selectedLocation: Location };
}>;
type PanelAnimationDoneActionType = ReadonlyDeep<{
type: typeof PANEL_ANIMATION_DONE;
payload: null;
}>;
type PanelAnimationStartedActionType = ReadonlyDeep<{
type: typeof PANEL_ANIMATION_STARTED;
payload: null;
}>;
export type NavActionType = ReadonlyDeep<
| ChangeLocationAction
| PanelAnimationDoneActionType
| PanelAnimationStartedActionType
>;
// Action Creators
export function changeLocation(
newLocation: Location
): ThunkAction<void, RootStateType, unknown, NavActionType> {
return async (dispatch, getState) => {
const existingLocation = getState().nav.selectedLocation;
const logId = `changeLocation/${printLocation(newLocation)}`;
const needToCancel = await beforeNavigateService.shouldCancelNavigation({
context: logId,
existingLocation,
newLocation,
});
if (needToCancel) {
log.info(`${logId}: Canceling navigation`);
return;
}
dispatch({
type: CHANGE_LOCATION,
payload: { selectedLocation: newLocation },
});
};
}
function panelAnimationStarted(): PanelAnimationStartedActionType {
return {
type: PANEL_ANIMATION_STARTED,
payload: null,
};
}
function panelAnimationDone(): PanelAnimationDoneActionType {
return {
type: PANEL_ANIMATION_DONE,
payload: null,
};
}
function pushPanelForConversation(
panel: PanelArgsType
): ThunkAction<void, RootStateType, unknown, ChangeLocationAction> {
return async (dispatch, getState) => {
const state = getState();
const existingLocation = getSelectedLocation(state);
const logId = `pushPanelForConversation/${panel.type}`;
if (existingLocation.tab !== NavTab.Chats) {
log.warn(`${logId}: Not on Chats tab; on ${existingLocation.tab} tab!`);
return;
}
const activePanel = getActivePanel(getState());
if (panel.type === activePanel?.type && isEqual(panel, activePanel)) {
log.warn(`${logId}: Already on ${panel.type} panel!`);
return;
}
const panels = getPanels(state) || getDefaultPanels();
const currentStack = panels.stack;
const watermark = Math.min(panels.watermark + 1, currentStack.length);
const stack = [...currentStack.slice(0, watermark), panel];
const newPanels = {
isAnimating: false,
wasAnimated: false,
direction: 'push' as const,
stack,
watermark,
};
const newLocation = {
...existingLocation,
details: {
...existingLocation.details,
panels: newPanels,
},
};
const needToCancel = await beforeNavigateService.shouldCancelNavigation({
context: logId,
existingLocation,
newLocation,
});
if (needToCancel) {
log.info(`${logId}: Canceling navigation`);
return;
}
dispatch({
type: CHANGE_LOCATION,
payload: { selectedLocation: newLocation },
});
};
}
export type PopPanelForConversationActionType = ReadonlyDeep<() => unknown>;
export function popPanelForConversation(): ThunkAction<
void,
RootStateType,
unknown,
ChangeLocationAction
> {
return async (dispatch, getState) => {
const state = getState();
const panels = getPanels(state);
const existingLocation = getSelectedLocation(state);
const logId = `popPanelForConversation/length=${panels?.stack.length}`;
if (existingLocation.tab !== NavTab.Chats) {
log.warn(`${logId}: Not on Chats tab; on ${existingLocation.tab} tab!`);
return;
}
if (!panels || panels.stack.length === 0) {
log.warn(`${logId}: No panel to pop!`);
return;
}
if (panels.watermark === -1) {
log.warn(`${logId}: Watermark is already -1`);
return;
}
const poppedPanel = panels.stack[panels.watermark];
if (!poppedPanel) {
log.warn(`${logId}: No panel found at watermark=${panels.watermark}`);
return;
}
const watermark = Math.max(panels.watermark - 1, -1);
const newPanels = {
isAnimating: false,
wasAnimated: false,
direction: 'pop' as const,
stack: panels.stack,
watermark,
};
const newLocation = {
...existingLocation,
details: {
...existingLocation.details,
panels: newPanels,
},
};
const needToCancel = await beforeNavigateService.shouldCancelNavigation({
context: logId,
existingLocation,
newLocation,
});
if (needToCancel) {
log.info(`${logId}: Canceling navigation`);
return;
}
dispatch({
type: CHANGE_LOCATION,
payload: { selectedLocation: newLocation },
});
};
}
export const actions = {
changeLocation,
panelAnimationDone,
panelAnimationStarted,
popPanelForConversation,
pushPanelForConversation,
};
export const useNavActions = (): BoundActionCreatorsMapObject<typeof actions> =>
useBoundActions(actions);
// Reducer
export function getEmptyState(): NavStateType {
return {
selectedLocation: {
tab: NavTab.Chats,
details: {
conversationId: undefined,
},
},
};
}
export function reducer(
state: Readonly<NavStateType> = getEmptyState(),
action: Readonly<NavActionType>
): NavStateType {
if (action.type === CHANGE_LOCATION) {
let { selectedLocation } = action.payload;
let { lastChatTabLocation } = state;
// Save last Chats Tab location if switching away from Chats Tab
if (
selectedLocation.tab !== NavTab.Chats &&
state.selectedLocation.tab === NavTab.Chats
) {
lastChatTabLocation = state.selectedLocation;
}
// Restore last Chats Tab location if:
// - switching back to Chats Tab
// - conversationId not set
if (
selectedLocation.tab === NavTab.Chats &&
!selectedLocation.details.conversationId &&
state.selectedLocation.tab !== NavTab.Chats &&
state.lastChatTabLocation
) {
selectedLocation = state.lastChatTabLocation;
}
return {
...state,
selectedLocation,
lastChatTabLocation,
};
}
if (action.type === PANEL_ANIMATION_STARTED) {
if (state.selectedLocation.tab !== NavTab.Chats) {
return state;
}
if (!state.selectedLocation.details.panels) {
return state;
}
return {
...state,
selectedLocation: {
...state.selectedLocation,
details: {
...state.selectedLocation.details,
panels: {
...state.selectedLocation.details.panels,
isAnimating: true,
},
},
},
};
}
if (action.type === PANEL_ANIMATION_DONE) {
if (state.selectedLocation.tab !== NavTab.Chats) {
return state;
}
if (!state.selectedLocation.details.panels) {
return state;
}
return {
...state,
selectedLocation: {
...state.selectedLocation,
details: {
...state.selectedLocation.details,
panels: {
...state.selectedLocation.details.panels,
isAnimating: false,
wasAnimated: true,
leafPanelOnly: false,
},
},
},
};
}
return state;
}