Settings Tab: Ensure that navigation to it is handled elsewhere

This commit is contained in:
Scott Nonnenberg
2025-05-21 09:03:31 +10:00
committed by GitHub
parent 15c826bc63
commit ffb2f3cb7e
16 changed files with 391 additions and 43 deletions

View File

@@ -152,6 +152,17 @@ window.ConversationController = window.ConversationController || {};
window.ConversationController.isSignalConversationId = () => false;
window.ConversationController.onConvoMessageMount = noop;
window.reduxStore = mockStore;
window.Signal = {
Services: {
beforeNavigate: {
registerCallback: () => undefined,
unregisterCallback: () => undefined,
shouldCancelNavigation: () => {
throw new Error('Not implemented');
},
},
},
};
function withStrictMode(Story, context) {
return (

View File

@@ -1361,12 +1361,42 @@ export async function startApp(): Promise<void> {
window.reduxActions.app.openStandalone();
});
window.Whisper.events.on('stageLocalBackupForImport', () => {
drop(backupsService._internalStageLocalBackupForImport());
let openingSettingsTab = false;
window.Whisper.events.on('openSettingsTab', async () => {
const logId = 'openSettingsTab';
try {
if (openingSettingsTab) {
log.info(
`${logId}: Already attempting to open settings tab, returning early`
);
return;
}
openingSettingsTab = true;
const newTab = NavTab.Settings;
const needToCancel =
await window.Signal.Services.beforeNavigate.shouldCancelNavigation({
context: logId,
newTab,
});
if (needToCancel) {
log.info(`${logId}: Cancelling navigation to the settings tab`);
return;
}
window.reduxActions.nav.changeNavTab(newTab);
} finally {
if (!openingSettingsTab) {
log.warn(`${logId}: openingSettingsTab was already false in finally!`);
}
openingSettingsTab = false;
}
});
window.Whisper.events.on('openSettingsTab', () => {
window.reduxActions.nav.changeNavTab(NavTab.Settings);
window.Whisper.events.on('stageLocalBackupForImport', () => {
drop(backupsService._internalStageLocalBackupForImport());
});
window.Whisper.events.on('powerMonitorSuspend', () => {

View File

@@ -97,6 +97,7 @@ import {
isEmojiVariantValue,
} from './fun/data/emojis';
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer';
import { BeforeNavigateResponse } from '../services/BeforeNavigate';
export type PropsType = {
activeCall: ActiveCallType;
@@ -314,6 +315,23 @@ export function CallScreen({
}, 5000);
return clearTimeout.bind(null, timer);
}, [showControls, showReactionPicker, stickyControls, controlsHover]);
useEffect(() => {
const name = 'CallScreen';
const callback = async () => {
togglePip();
return BeforeNavigateResponse.MadeChanges;
};
window.Signal.Services.beforeNavigate.registerCallback({
callback,
name,
});
return () => {
window.Signal.Services.beforeNavigate.unregisterCallback({
callback,
name,
});
};
}, [togglePip]);
const [selfViewHover, setSelfViewHover] = useState(false);
const onSelfViewMouseEnter = useCallback(() => {

View File

@@ -1075,6 +1075,7 @@ export const CompositionArea = memo(function CompositionArea({
imageSrc={attachmentToEdit.url}
imageToBlurHash={imageToBlurHash}
installedPacks={installedPacks}
isCreatingStory={false}
isFormattingEnabled={isFormattingEnabled}
isSending={false}
onClose={() => setAttachmentToEdit(undefined)}

View File

@@ -78,6 +78,7 @@ export type MediaEditorResultType = Readonly<{
}>;
export type PropsType = {
isCreatingStory: boolean;
doneButtonLabel?: string;
i18n: LocalizerType;
imageSrc: string;
@@ -152,6 +153,7 @@ export function MediaEditor({
doneButtonLabel,
i18n,
imageSrc,
isCreatingStory,
isSending,
onClose,
onDone,
@@ -370,11 +372,17 @@ export function MediaEditor({
const [editMode, setEditMode] = useState<EditMode | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);
const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
name: 'MediaEditor',
tryClose,
});
const onTryClose = useCallback(() => {
confirmDiscardIf(canUndo, onClose);
}, [confirmDiscardIf, canUndo, onClose]);
confirmDiscardIf(canUndo || isCreatingStory, onClose);
}, [confirmDiscardIf, canUndo, isCreatingStory, onClose]);
tryClose.current = onTryClose;
// Keyboard support
useEffect(() => {

View File

@@ -1,7 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { noop, sortBy } from 'lodash';
import { SearchInput } from './SearchInput';
@@ -151,7 +151,10 @@ export function SendStoryModal({
}: PropsType): JSX.Element {
const [page, setPage] = useState<PageType>(Page.SendStory);
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
name: 'SendStoryModal',
});
const [selectedListIds, setSelectedListIds] = useState<
Set<StoryDistributionIdString>
@@ -944,13 +947,17 @@ export function SendStoryModal({
);
}
const onTryClose = useCallback(() => {
confirmDiscardIf(selectedContacts.length > 0, onClose);
}, [confirmDiscardIf, selectedContacts, onClose]);
return (
<>
{!confirmDiscardModal && (
<PagedModal
modalName="SendStoryModal"
theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light}
onClose={() => confirmDiscardIf(selectedContacts.length > 0, onClose)}
onClose={onTryClose}
>
{modal}
</PagedModal>

View File

@@ -2,7 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { noop } from 'lodash';
import type { ConversationType } from '../state/ducks/conversations';
@@ -261,7 +267,12 @@ export function StoriesSettingsModal({
setStoriesDisabled,
getConversationByServiceId,
}: PropsType): JSX.Element {
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard(i18n);
const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
name: 'StoriesSettingsModal',
tryClose,
});
const [listToEditId, setListToEditId] = useState<string | undefined>(
undefined
@@ -284,6 +295,10 @@ export function StoriesSettingsModal({
const [selectedContacts, setSelectedContacts] = useState<
Array<ConversationType>
>([]);
const onTryClose = useCallback(() => {
confirmDiscardIf(selectedContacts.length > 0, hideStoriesSettings);
}, [confirmDiscardIf, selectedContacts, hideStoriesSettings]);
tryClose.current = onTryClose;
const resetChooseViewersScreen = useCallback(() => {
setSelectedContacts([]);
@@ -482,9 +497,7 @@ export function StoriesSettingsModal({
<PagedModal
modalName="StoriesSettingsModal"
moduleClassName="StoriesSettingsModal"
onClose={() =>
confirmDiscardIf(selectedContacts.length > 0, hideStoriesSettings)
}
onClose={onTryClose}
>
{modal}
</PagedModal>

View File

@@ -254,6 +254,7 @@ export function StoryCreator({
imageSrc={attachmentUrl}
imageToBlurHash={imageToBlurHash}
installedPacks={installedPacks}
isCreatingStory
isFormattingEnabled={isFormattingEnabled}
isSending={isSending}
onClose={onClose}

View File

@@ -41,6 +41,7 @@ import { isFunPickerEnabled } from './fun/isFunPickerEnabled';
import { FunEmojiPicker } from './fun/FunEmojiPicker';
import { FunEmojiPickerButton } from './fun/FunButton';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
// Menu is disabled so these actions are inaccessible. We also don't support
// link previews, tap to view messages, attachments, or gifts. Just regular
@@ -241,6 +242,17 @@ export function StoryViewsNRepliesModal({
}
}, [currentTab, replies.length]);
const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
name: 'StoryViewsNRepliesModal',
tryClose,
});
const onTryClose = useCallback(() => {
confirmDiscardIf(emojiPickerOpen || messageBodyText.length > 0, onClose);
}, [confirmDiscardIf, emojiPickerOpen, messageBodyText, onClose]);
tryClose.current = onTryClose;
if (group && group.left) {
composerElement = (
<div className="StoryViewsNRepliesModal__not-a-member">
@@ -484,6 +496,10 @@ export function StoryViewsNRepliesModal({
return null;
}
if (confirmDiscardModal) {
return confirmDiscardModal;
}
return (
<>
<Modal
@@ -493,7 +509,7 @@ export function StoryViewsNRepliesModal({
StoryViewsNRepliesModal: true,
'StoryViewsNRepliesModal--group': Boolean(group),
})}
onClose={onClose}
onClose={onTryClose}
padded={false}
theme={Theme.Dark}
>

View File

@@ -29,13 +29,13 @@ import {
import { convertShortName } from './emoji/lib';
import { objectMap } from '../util/objectMap';
import { handleOutsideClick } from '../util/handleOutsideClick';
import { ConfirmDiscardDialog } from './ConfirmDiscardDialog';
import { Spinner } from './Spinner';
import { FunEmojiPicker } from './fun/FunEmojiPicker';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
import { getEmojiVariantByKey } from './fun/data/emojis';
import { FunEmojiPickerButton } from './fun/FunButton';
import { isFunPickerEnabled } from './fun/isFunPickerEnabled';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
export type PropsType = {
debouncedMaybeGrabLinkPreview: (
@@ -148,11 +148,16 @@ export function TextStoryCreator({
recentEmojis,
emojiSkinToneDefault,
}: PropsType): JSX.Element {
const [showConfirmDiscardModal, setShowConfirmDiscardModal] = useState(false);
const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
name: 'SendStoryModal',
tryClose,
});
const onTryClose = useCallback(() => {
setShowConfirmDiscardModal(true);
}, [setShowConfirmDiscardModal]);
confirmDiscardIf(true, onClose);
}, [confirmDiscardIf, onClose]);
tryClose.current = onTryClose;
const [isEditingText, setIsEditingText] = useState(false);
const [selectedBackground, setSelectedBackground] =
@@ -290,8 +295,6 @@ export function TextStoryCreator({
isEditingText,
isLinkPreviewInputShowing,
colorPickerPopperButtonRef,
showConfirmDiscardModal,
setShowConfirmDiscardModal,
onTryClose,
]);
@@ -653,13 +656,7 @@ export function TextStoryCreator({
</Button>
</div>
</div>
{showConfirmDiscardModal && (
<ConfirmDiscardDialog
i18n={i18n}
onClose={() => setShowConfirmDiscardModal(false)}
onDiscard={onClose}
/>
)}
{confirmDiscardModal}
</div>
</FocusScope>
);

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { FormEventHandler } from 'react';
import React, { useRef, useState } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import type { LocalizerType } from '../../../types/Util';
import { Modal } from '../../Modal';
@@ -20,6 +20,7 @@ import type {
SaveAvatarToDiskActionType,
} from '../../../types/Avatar';
import type { AvatarColorType } from '../../../types/Colors';
import { useConfirmDiscard } from '../../../hooks/useConfirmDiscard';
type PropsType = {
avatarColor?: AvatarColorType;
@@ -79,6 +80,13 @@ export function EditConversationAttributesModal({
const trimmedTitle = rawTitle.trim();
const trimmedDescription = rawGroupDescription.trim();
const tryClose = useRef<() => void | undefined>();
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
name: 'EditConversationAttributesModal',
tryClose,
});
const focusRef = (el: null | HTMLElement) => {
if (el) {
el.focus();
@@ -103,6 +111,26 @@ export function EditConversationAttributesModal({
hasGroupDescriptionChanged) &&
trimmedTitle.length > 0;
const onTryClose = useCallback(() => {
confirmDiscardIf(
isRequestActive ||
hasAvatarChanged ||
hasChangedExternally ||
hasGroupDescriptionChanged ||
hasTitleChanged,
onClose
);
}, [
confirmDiscardIf,
isRequestActive,
hasAvatarChanged,
hasChangedExternally,
hasGroupDescriptionChanged,
hasTitleChanged,
onClose,
]);
tryClose.current = onTryClose;
const onSubmit: FormEventHandler<HTMLFormElement> = event => {
event.preventDefault();
@@ -228,12 +256,16 @@ export function EditConversationAttributesModal({
</>
);
if (confirmDiscardModal) {
return confirmDiscardModal;
}
return (
<Modal
modalName="EditConversationAttributesModal"
hasXButton
i18n={i18n}
onClose={onClose}
onClose={onTryClose}
title={i18n('icu:updateGroupAttributes__title')}
modalFooter={modalFooter}
>

View File

@@ -1,31 +1,90 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { PropsType } from '../components/ConfirmDiscardDialog';
import React, { useEffect, useRef, useState } from 'react';
import { ConfirmDiscardDialog } from '../components/ConfirmDiscardDialog';
import { BeforeNavigateResponse } from '../services/BeforeNavigate';
import {
explodePromise,
type ExplodePromiseResultType,
} from '../util/explodePromise';
import type { PropsType } from '../components/ConfirmDiscardDialog';
import type { LocalizerType } from '../types/Util';
export function useConfirmDiscard(
i18n: LocalizerType
): [JSX.Element | null, (condition: boolean, callback: () => void) => void] {
export function useConfirmDiscard({
i18n,
name,
tryClose,
}: {
i18n: LocalizerType;
name: string;
tryClose?: React.MutableRefObject<(() => void) | undefined>;
}): [
JSX.Element | null,
(condition: boolean, discardChanges: () => void, cancel?: () => void) => void,
] {
const [props, setProps] = useState<Omit<PropsType, 'i18n'> | null>(null);
const confirmElement = props ? (
<ConfirmDiscardDialog i18n={i18n} {...props} />
) : null;
const confirmDiscardPromise = useRef<
ExplodePromiseResultType<BeforeNavigateResponse> | undefined
>();
function confirmDiscardIf(condition: boolean, callback: () => void) {
useEffect(() => {
if (!tryClose) {
return;
}
const callback = async () => {
const close = tryClose.current;
if (!close) {
return BeforeNavigateResponse.Noop;
}
confirmDiscardPromise.current = explodePromise<BeforeNavigateResponse>();
close();
return confirmDiscardPromise.current.promise;
};
window.Signal.Services.beforeNavigate.registerCallback({
name,
callback,
});
return () => {
window.Signal.Services.beforeNavigate.unregisterCallback({
name,
callback,
});
};
}, [name, tryClose, confirmDiscardPromise]);
function confirmDiscardIf(
condition: boolean,
discardChanges: () => void,
cancel?: () => void
) {
if (condition) {
setProps({
onClose() {
confirmDiscardPromise.current?.resolve(
BeforeNavigateResponse.CancelNavigation
);
setProps(null);
cancel?.();
},
onDiscard() {
confirmDiscardPromise.current?.resolve(
BeforeNavigateResponse.WaitedForUser
);
discardChanges();
},
onDiscard: callback,
});
} else {
callback();
confirmDiscardPromise.current?.resolve(BeforeNavigateResponse.Noop);
discardChanges();
}
}

View File

@@ -0,0 +1,103 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../logging/log';
import type { NavTab } from '../state/ducks/nav';
import { SECOND } from '../util/durations';
import { sleep } from '../util/sleep';
export enum BeforeNavigateResponse {
Noop = 'Noop',
MadeChanges = 'MadeChanges',
WaitedForUser = 'WaitedForUser',
CancelNavigation = 'CancelNavigation',
TimedOut = 'TimedOut',
}
export type BeforeNavigateCallback = (
newTab: NavTab
) => Promise<BeforeNavigateResponse>;
export type BeforeNavigateEntry = {
name: string;
callback: BeforeNavigateCallback;
};
export class BeforeNavigateService {
#beforeNavigateCallbacks = new Set<BeforeNavigateEntry>();
private findMatchingEntry(
entry: BeforeNavigateEntry
): BeforeNavigateEntry | undefined {
const { callback } = entry;
return Array.from(this.#beforeNavigateCallbacks).find(
item => item.callback === callback
);
}
registerCallback(entry: BeforeNavigateEntry): void {
const logId = 'registerCallback';
const existing = this.findMatchingEntry(entry);
if (existing) {
log.warn(
`${logId}: Overwriting duplicate callback for entry ${entry.name}`
);
this.#beforeNavigateCallbacks.delete(existing);
}
this.#beforeNavigateCallbacks.add(entry);
}
unregisterCallback(entry: BeforeNavigateEntry): void {
const logId = 'unregisterCallback';
const existing = this.findMatchingEntry(entry);
if (!existing) {
log.warn(
`${logId}: Didn't find matching callback for entry ${entry.name}`
);
return;
}
this.#beforeNavigateCallbacks.delete(existing);
}
async shouldCancelNavigation({
context,
newTab,
}: {
context: string;
newTab: NavTab;
}): Promise<boolean> {
const logId = `shouldCancelNavigation/${context}`;
const entries = Array.from(this.#beforeNavigateCallbacks);
for (let i = 0, max = entries.length; i < max; i += 1) {
const entry = entries[i];
// eslint-disable-next-line no-await-in-loop
const response = await Promise.race([
entry.callback(newTab),
timeOutAfter(5 * SECOND),
]);
if (response === BeforeNavigateResponse.Noop) {
continue;
}
log.info(`${logId}: ${entry.name} returned result ${response}`);
if (
response === BeforeNavigateResponse.CancelNavigation ||
response === BeforeNavigateResponse.TimedOut
) {
return true;
}
}
return false;
}
}
async function timeOutAfter(ms: number): Promise<BeforeNavigateResponse> {
await sleep(ms);
return BeforeNavigateResponse.TimedOut;
}
export const beforeNavigateService = new BeforeNavigateService();

View File

@@ -54,6 +54,7 @@ import type {
LinkPreviewWithHydratedData,
} from './types/message/LinkPreviews';
import type { StickerType, StickerWithHydratedData } from './types/Stickers';
import { beforeNavigateService } from './services/BeforeNavigate';
type EncryptedReader = (
attachment: Partial<AddressableAttachmentType>
@@ -467,6 +468,7 @@ export const setup = (options: {
const Services = {
backups: backupsService,
beforeNavigate: beforeNavigateService,
calling,
initializeGroupCredentialFetcher,
initializeNetworkObserver,

View File

@@ -1348,6 +1348,14 @@
"reasonCategory": "usageTrusted",
"updated": "2023-09-11T20:19:18.681Z"
},
{
"rule": "React-useRef",
"path": "ts/components/MediaEditor.tsx",
"line": " const tryClose = useRef<() => void | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2025-05-19T22:29:15.758Z",
"reasonDetail": "Holding on to a close function"
},
{
"rule": "React-useRef",
"path": "ts/components/MediaQualitySelector.tsx",
@@ -1456,6 +1464,14 @@
"reasonCategory": "usageTrusted",
"updated": "2024-11-16T00:33:41.092Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoriesSettingsModal.tsx",
"line": " const tryClose = useRef<() => void | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2025-05-19T22:29:15.758Z",
"reasonDetail": "Holding on to a close function"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryImage.tsx",
@@ -1498,6 +1514,14 @@
"reasonCategory": "usageTrusted",
"updated": "2022-10-05T18:51:56.411Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryViewsNRepliesModal.tsx",
"line": " const tryClose = useRef<() => void | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2025-05-19T23:20:52.448Z",
"reasonDetail": "Holding on to a close function"
},
{
"rule": "React-useRef",
"path": "ts/components/TextAttachment.tsx",
@@ -1526,6 +1550,14 @@
"reasonCategory": "usageTrusted",
"updated": "2022-06-16T23:23:32.306Z"
},
{
"rule": "React-useRef",
"path": "ts/components/TextStoryCreator.tsx",
"line": " const tryClose = useRef<() => void | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2025-05-19T22:29:15.758Z",
"reasonDetail": "Holding on to a close function"
},
{
"rule": "React-useRef",
"path": "ts/components/Tooltip.tsx",
@@ -1687,6 +1719,14 @@
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/conversation-details/EditConversationAttributesModal.tsx",
"line": " const tryClose = useRef<() => void | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2025-05-19T23:20:52.448Z",
"reasonDetail": "Holding on to a close function"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/media-gallery/MediaGallery.tsx",
@@ -1911,6 +1951,14 @@
"updated": "2022-06-14T22:04:43.988Z",
"reasonDetail": "Handling outside click"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useConfirmDiscard.tsx",
"line": " const confirmDiscardPromise = useRef<",
"reasonCategory": "usageTrusted",
"updated": "2025-05-19T22:29:15.758Z",
"reasonDetail": "Holding on to a promise"
},
{
"rule": "React-useRef",
"path": "ts/hooks/useIntersectionObserver.ts",

6
ts/window.d.ts vendored
View File

@@ -53,6 +53,7 @@ import type { PropsPreloadType as PreferencesPropsType } from './components/Pref
import type { WindowsNotificationData } from './services/notifications';
import type { QueryStatsOptions } from './sql/main';
import type { SocketStatuses } from './textsecure/SocketManager';
import type { BeforeNavigateService } from './services/BeforeNavigate';
export { Long } from 'long';
@@ -151,16 +152,17 @@ export type SignalCoreType = {
RemoteConfig: typeof RemoteConfig;
ScreenShareWindowProps?: ScreenShareWindowPropsType;
Services: {
calling: CallingClass;
backups: BackupsService;
beforeNavigate: BeforeNavigateService;
calling: CallingClass;
initializeGroupCredentialFetcher: () => Promise<void>;
initializeNetworkObserver: (
network: ReduxActions['network'],
getAuthSocketStatus: () => SocketStatus
) => void;
initializeUpdateListener: (updates: ReduxActions['updates']) => void;
retryPlaceholders?: RetryPlaceholders;
lightSessionResetQueue?: PQueue;
retryPlaceholders?: RetryPlaceholders;
storage: typeof StorageService;
};
SettingsWindowProps?: SettingsWindowPropsType;