From 5748fe545673d7ecd37fb3996fb0cbdf5ed4a73a Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 3 Feb 2025 16:31:13 +1100 Subject: [PATCH] chore: disable a bunch of UI once legacy groups are deprecated --- preload.js | 1 + ts/components/SessionInboxView.tsx | 2 + .../header/ConversationHeaderItems.tsx | 4 +- .../header/ConversationHeaderTitle.tsx | 6 +++ .../message-item/GenericReadableMessage.tsx | 7 ++- .../message/reactions/Reaction.tsx | 9 +++- ts/components/leftpane/ActionsPanel.tsx | 3 ++ .../menu/ConversationListItemContextMenu.tsx | 18 +++++++- .../DeleteGroupMenuItem.tsx | 24 ++++++++++ .../useRefreshReleasedFeaturesTimestamp.ts | 46 +++++++++++++++++++ ts/session/apis/snode_api/swarmPolling.ts | 15 ++++-- ts/state/ducks/releasedFeatures.tsx | 25 ++++++++++ ts/state/reducer.ts | 3 ++ ts/state/selectors/index.ts | 2 + ts/state/selectors/releasedFeatures.ts | 21 +++++++++ ts/state/selectors/selectedConversation.ts | 22 ++++++--- ts/util/releaseFeature.ts | 1 + ts/window.d.ts | 1 + 18 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 ts/hooks/useRefreshReleasedFeaturesTimestamp.ts create mode 100644 ts/state/ducks/releasedFeatures.tsx create mode 100644 ts/state/selectors/releasedFeatures.ts diff --git a/preload.js b/preload.js index 199e72a82..c1d294df8 100644 --- a/preload.js +++ b/preload.js @@ -41,6 +41,7 @@ window.sessionFeatureFlags = { useOnionRequests: true, useTestNet: isTestNet() || isTestIntegration(), useClosedGroupV2: true, // TODO DO NOT MERGE Remove after QA + forceLegacyGroupsDeprecated: false, // TODO DO NOT MERGE Remove after QA useClosedGroupV2QAButtons: true, // TODO DO NOT MERGE Remove after QA replaceLocalizedStringsWithKeys: false, debug: { diff --git a/ts/components/SessionInboxView.tsx b/ts/components/SessionInboxView.tsx index bff9301b2..c1e7c5c4d 100644 --- a/ts/components/SessionInboxView.tsx +++ b/ts/components/SessionInboxView.tsx @@ -40,6 +40,7 @@ import { Storage } from '../util/storage'; import { UserGroupsWrapperActions } from '../webworker/workers/browser/libsession_worker_interface'; import { NoticeBanner } from './NoticeBanner'; import { Flex } from './basic/Flex'; +import { initialReleasedFeaturesState } from '../state/ducks/releasedFeatures'; function makeLookup(items: Array, key: string): { [key: string]: T } { // Yep, we can't index into item without knowing what it is. True. But we want to. @@ -88,6 +89,7 @@ async function createSessionInboxStore() { settings: getSettingsInitialState(), groups: initialGroupState, userGroups: { userGroups }, + releasedFeatures: initialReleasedFeaturesState, }; return createStore(initialState); diff --git a/ts/components/conversation/header/ConversationHeaderItems.tsx b/ts/components/conversation/header/ConversationHeaderItems.tsx index 39d05e8f7..c92d94eae 100644 --- a/ts/components/conversation/header/ConversationHeaderItems.tsx +++ b/ts/components/conversation/header/ConversationHeaderItems.tsx @@ -12,19 +12,21 @@ import { } from '../../../state/selectors/selectedConversation'; import { Avatar, AvatarSize } from '../../avatar/Avatar'; import { SessionIconButton } from '../../icon'; +import { useDisableLegacyGroupDeprecatedActions } from '../../../hooks/useRefreshReleasedFeaturesTimestamp'; export const AvatarHeader = (props: { pubkey: string; onAvatarClick?: (pubkey: string) => void; }) => { const { pubkey, onAvatarClick } = props; + const isDisabledLegacyGroupDeprecated = useDisableLegacyGroupDeprecatedActions(pubkey); return ( { - if (onAvatarClick) { + if (onAvatarClick && !isDisabledLegacyGroupDeprecated) { onAvatarClick(pubkey); } }} diff --git a/ts/components/conversation/header/ConversationHeaderTitle.tsx b/ts/components/conversation/header/ConversationHeaderTitle.tsx index 4da8d91eb..e1d64ce88 100644 --- a/ts/components/conversation/header/ConversationHeaderTitle.tsx +++ b/ts/components/conversation/header/ConversationHeaderTitle.tsx @@ -18,6 +18,7 @@ import { useSelectedSubscriberCount, } from '../../../state/selectors/selectedConversation'; import { ConversationHeaderSubtitle } from './ConversationHeaderSubtitle'; +import { useSelectedDisableLegacyGroupDeprecatedActions } from '../../../hooks/useRefreshReleasedFeaturesTimestamp'; export type SubtitleStrings = Record & { notifications?: string; @@ -63,6 +64,8 @@ export const ConversationHeaderTitle = (props: ConversationHeaderTitleProps) => const isGroup = useSelectedIsGroupOrCommunity(); const selectedMembersCount = useSelectedMembersCount(); + const isDisabledLegacyGroupDeprecated = useSelectedDisableLegacyGroupDeprecatedActions(); + const expirationMode = useSelectedConversationDisappearingMode(); const disappearingMessageSubtitle = useDisappearingMessageSettingText({ convoId, @@ -97,6 +100,9 @@ export const ConversationHeaderTitle = (props: ConversationHeaderTitleProps) => }, [i18n, isGroup, isKickedFromGroup, isPublic, selectedMembersCount, subscriberCount]); const handleRightPanelToggle = () => { + if (isDisabledLegacyGroupDeprecated) { + return; + } if (isRightPanelOn) { dispatch(closeRightPanel()); return; diff --git a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx index cca3dbf56..f53ea150d 100644 --- a/ts/components/conversation/message/message-item/GenericReadableMessage.tsx +++ b/ts/components/conversation/message/message-item/GenericReadableMessage.tsx @@ -13,6 +13,7 @@ import { getGenericReadableMessageSelectorProps } from '../../../../state/select import { MessageContentWithStatuses } from '../message-content/MessageContentWithStatus'; import { StyledMessageReactionsContainer } from '../message-content/MessageReactions'; import { useIsMessageSelectionMode } from '../../../../state/selectors/selectedConversation'; +import { useSelectedDisableLegacyGroupDeprecatedActions } from '../../../../hooks/useRefreshReleasedFeaturesTimestamp'; export type GenericReadableMessageSelectorProps = Pick< MessageRenderingProps, @@ -65,6 +66,7 @@ export const GenericReadableMessage = (props: Props) => { const { ctxMenuID, messageId } = props; const [enableReactions, setEnableReactions] = useState(true); + const legacyGroupIsDeprecated = useSelectedDisableLegacyGroupDeprecatedActions(); const msgProps = useSelector((state: StateType) => getGenericReadableMessageSelectorProps(state, props.messageId) @@ -83,6 +85,9 @@ export const GenericReadableMessage = (props: Props) => { const handleContextMenu = useCallback( (e: MouseEvent) => { + if (legacyGroupIsDeprecated) { + return; + } // this is quite dirty but considering that we want the context menu of the message to show on click on the attachment // and the context menu save attachment item to save the right attachment I did not find a better way for now. @@ -108,7 +113,7 @@ export const GenericReadableMessage = (props: Props) => { } setIsRightClicked(enableContextMenu); }, - [ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup] + [ctxMenuID, multiSelectMode, msgProps?.isKickedFromGroup, legacyGroupIsDeprecated] ); useEffect(() => { diff --git a/ts/components/conversation/message/reactions/Reaction.tsx b/ts/components/conversation/message/reactions/Reaction.tsx index a82de029d..d1c266e7d 100644 --- a/ts/components/conversation/message/reactions/Reaction.tsx +++ b/ts/components/conversation/message/reactions/Reaction.tsx @@ -11,6 +11,7 @@ import { abbreviateNumber } from '../../../../util/abbreviateNumber'; import { nativeEmojiData } from '../../../../util/emoji'; import { popupXDefault, popupYDefault } from '../message-content/MessageReactions'; import { POPUP_WIDTH, ReactionPopup, TipPosition } from './ReactionPopup'; +import { useSelectedDisableLegacyGroupDeprecatedActions } from '../../../../hooks/useRefreshReleasedFeaturesTimestamp'; const StyledReaction = styled.button<{ selected: boolean; @@ -79,6 +80,8 @@ export const Reaction = (props: ReactionProps) => { } = props; const rightOverlayMode = useRightOverlayMode(); + const areDeprecatedLegacyGroupDisabled = useSelectedDisableLegacyGroupDeprecatedActions(); + const legacyGroupDeprecated = useSelectedDisableLegacyGroupDeprecatedActions(); const isMessageSelection = useIsMessageSelectionMode(); const reactionsMap = (reactions && Object.fromEntries(reactions)) || {}; const senders = reactionsMap[emoji]?.senders || []; @@ -106,7 +109,8 @@ export const Reaction = (props: ReactionProps) => { const handleReactionClick = () => { if (!isMessageSelection) { - if (onClick) { + // Note: disable emoji clicks if the legacy group is deprecated (group is readonly) + if (onClick && !legacyGroupDeprecated) { onClick(emoji); } } @@ -174,6 +178,9 @@ export const Reaction = (props: ReactionProps) => { senders={reactionsMap[popupReaction]?.senders} tooltipPosition={tooltipPosition} onClick={() => { + if (areDeprecatedLegacyGroupDisabled) { + return; + } if (handlePopupReaction) { handlePopupReaction(''); } diff --git a/ts/components/leftpane/ActionsPanel.tsx b/ts/components/leftpane/ActionsPanel.tsx index ec1842774..d2f3c0339 100644 --- a/ts/components/leftpane/ActionsPanel.tsx +++ b/ts/components/leftpane/ActionsPanel.tsx @@ -49,6 +49,7 @@ import { getIsModalVisible } from '../../state/selectors/modal'; import { ReleasedFeatures } from '../../util/releaseFeature'; import { MessageQueue } from '../../session/sending'; +import { useRefreshReleasedFeaturesTimestamp } from '../../hooks/useRefreshReleasedFeaturesTimestamp'; const Section = (props: { type: SectionType }) => { const ourNumber = useSelector(getOurNumber); @@ -297,6 +298,8 @@ export const ActionsPanel = () => { void triggerAvatarReUploadIfNeeded(); }, DURATION.DAYS * 1); + useRefreshReleasedFeaturesTimestamp(); + if (!ourPrimaryConversation) { window?.log?.warn('ActionsPanel: ourPrimaryConversation is not set'); return null; diff --git a/ts/components/menu/ConversationListItemContextMenu.tsx b/ts/components/menu/ConversationListItemContextMenu.tsx index 83aeb1964..ffe476a78 100644 --- a/ts/components/menu/ConversationListItemContextMenu.tsx +++ b/ts/components/menu/ConversationListItemContextMenu.tsx @@ -33,7 +33,11 @@ import { ItemWithDataTestId } from './items/MenuItemWithDataTestId'; import { getMenuAnimation } from './MenuAnimation'; import { LeaveCommunityMenuItem } from './items/LeaveCommunity/LeaveCommunityMenuItem'; import { LeaveGroupMenuItem } from './items/LeaveAndDeleteGroup/LeaveGroupMenuItem'; -import { DeleteGroupMenuItem } from './items/LeaveAndDeleteGroup/DeleteGroupMenuItem'; +import { + DeleteDeprecatedLegacyGroupMenuItem, + DeleteGroupMenuItem, +} from './items/LeaveAndDeleteGroup/DeleteGroupMenuItem'; +import { useDisableLegacyGroupDeprecatedActions } from '../../hooks/useRefreshReleasedFeaturesTimestamp'; export type PropsContextConversationItem = { triggerId: string; @@ -45,10 +49,22 @@ const ConversationListItemContextMenu = (props: PropsContextConversationItem) => const convoIdFromContext = useConvoIdFromContext(); + const disabledLegacyGroup = useDisableLegacyGroupDeprecatedActions(convoIdFromContext); + if (isSearching) { return null; } + if (disabledLegacyGroup) { + return ( + + + + + + ); + } + return ( diff --git a/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx b/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx index 9fdc5aaa9..415b14e56 100644 --- a/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx +++ b/ts/components/menu/items/LeaveAndDeleteGroup/DeleteGroupMenuItem.tsx @@ -12,6 +12,7 @@ import { useIsMessageRequestOverlayShown } from '../../../../state/selectors/sec import { ItemWithDataTestId } from '../MenuItemWithDataTestId'; import { showDeleteGroupItem } from './guard'; import { Localizer } from '../../../basic/Localizer'; +import { useDisableLegacyGroupDeprecatedActions } from '../../../../hooks/useRefreshReleasedFeaturesTimestamp'; export const DeleteGroupMenuItem = () => { const convoId = useConvoIdFromContext(); @@ -46,3 +47,26 @@ export const DeleteGroupMenuItem = () => { ); }; + +export const DeleteDeprecatedLegacyGroupMenuItem = () => { + const convoId = useConvoIdFromContext(); + const username = useConversationUsername(convoId) || convoId; + + const shortCircuitDeleteDeprecatedGroup = useDisableLegacyGroupDeprecatedActions(convoId); + + if (!shortCircuitDeleteDeprecatedGroup) { + return null; + } + + const token = 'groupDelete'; + + return ( + { + void showDeleteGroupByConvoId(convoId, username); + }} + > + + + ); +}; diff --git a/ts/hooks/useRefreshReleasedFeaturesTimestamp.ts b/ts/hooks/useRefreshReleasedFeaturesTimestamp.ts new file mode 100644 index 000000000..8ffc21135 --- /dev/null +++ b/ts/hooks/useRefreshReleasedFeaturesTimestamp.ts @@ -0,0 +1,46 @@ +import useInterval from 'react-use/lib/useInterval'; +import { useDispatch, useSelector } from 'react-redux'; +import { DURATION } from '../session/constants'; +import { updateLegacyGroupDeprecationTimestampUpdatedAt } from '../state/ducks/releasedFeatures'; +import { NetworkTime } from '../util/NetworkTime'; +import { PubKey } from '../session/types'; +import { areLegacyGroupsDeprecatedYet } from '../state/selectors/releasedFeatures'; +import { useSelectedConversationKey } from '../state/selectors/selectedConversation'; +import type { StateType } from '../state/reducer'; +import { ConversationTypeEnum } from '../models/types'; + +export function useRefreshReleasedFeaturesTimestamp() { + const dispatch = useDispatch(); + + useInterval(() => { + const nowFromNetwork = NetworkTime.now(); + dispatch(updateLegacyGroupDeprecationTimestampUpdatedAt(nowFromNetwork)); + }, 1 * DURATION.SECONDS); +} + +export function getDisableLegacyGroupDeprecatedActions(state: StateType, convoId?: string) { + if (!convoId || !PubKey.is05Pubkey(convoId)) { + return false; + } + const selectedConvoIsGroup = + state.conversations.conversationLookup[convoId]?.type === ConversationTypeEnum.GROUP; + if (!selectedConvoIsGroup) { + return false; + } + const legacyGroupDeprecated = areLegacyGroupsDeprecatedYet(); + // here we have + // - a valid convoId + // - that starts with 05 + // - that is a group (i.e. a legacy group) + // - and legacy group deprecation date has been hit + return legacyGroupDeprecated; +} + +export function useDisableLegacyGroupDeprecatedActions(convoId?: string) { + return useSelector((state: StateType) => getDisableLegacyGroupDeprecatedActions(state, convoId)); +} + +export function useSelectedDisableLegacyGroupDeprecatedActions() { + const convoId = useSelectedConversationKey(); + return useDisableLegacyGroupDeprecatedActions(convoId); +} diff --git a/ts/session/apis/snode_api/swarmPolling.ts b/ts/session/apis/snode_api/swarmPolling.ts index 0d06fbb0e..b483ba9c6 100644 --- a/ts/session/apis/snode_api/swarmPolling.ts +++ b/ts/session/apis/snode_api/swarmPolling.ts @@ -58,6 +58,7 @@ import { } from './types'; import { ConversationTypeEnum } from '../../../models/types'; import { Snode } from '../../../data/types'; +import { areLegacyGroupsDeprecatedYetOutsideRedux } from '../../../state/selectors/releasedFeatures'; const minMsgCountShouldRetry = 95; /** @@ -296,11 +297,15 @@ export class SwarmPolling { .filter(m => !allGroupsInWrapper.some(w => w.pubkeyHex === m.pubkey.key)) .map(entryToKey); - const allLegacyGroupsTracked = legacyGroups - .filter(m => this.shouldPollByTimeout(m)) // should we poll from it depending on this group activity? - .filter(m => allGroupsLegacyInWrapper.some(w => w.pubkeyHex === m.pubkey.key)) // we don't poll from legacy groups which are not in the user group wrapper - .map(m => m.pubkey.key) // extract the pubkey - .map(m => [m, ConversationTypeEnum.GROUP] as PollForLegacy); // + const legacyGroupDeprecatedDisabled = areLegacyGroupsDeprecatedYetOutsideRedux(); + + const allLegacyGroupsTracked = legacyGroupDeprecatedDisabled + ? [] + : legacyGroups + .filter(m => this.shouldPollByTimeout(m)) // should we poll from it depending on this group activity? + .filter(m => allGroupsLegacyInWrapper.some(w => w.pubkeyHex === m.pubkey.key)) // we don't poll from legacy groups which are not in the user group wrapper + .map(m => m.pubkey.key) // extract the pubkey + .map(m => [m, ConversationTypeEnum.GROUP] as PollForLegacy); // toPollDetails = concat(toPollDetails, allLegacyGroupsTracked); const allGroupsTracked = groups diff --git a/ts/state/ducks/releasedFeatures.tsx b/ts/state/ducks/releasedFeatures.tsx new file mode 100644 index 000000000..e66ec1bc0 --- /dev/null +++ b/ts/state/ducks/releasedFeatures.tsx @@ -0,0 +1,25 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; + +export const LEGACY_GROUP_DEPRECATED_TIMESTAMP_MS = Date.now() + 10 * 1000; + +export interface ReleasedFeaturesState { + legacyGroupDeprecationTimestampRefreshAtMs: number; +} + +export const initialReleasedFeaturesState = { + legacyGroupDeprecationTimestampRefreshAtMs: Date.now(), +}; + +const releasedFeaturesSlice = createSlice({ + name: 'releasedFeatures', + initialState: initialReleasedFeaturesState, + reducers: { + updateLegacyGroupDeprecationTimestampUpdatedAt: (state, action: PayloadAction) => { + state.legacyGroupDeprecationTimestampRefreshAtMs = action.payload; + }, + }, +}); + +const { actions, reducer } = releasedFeaturesSlice; +export const { updateLegacyGroupDeprecationTimestampUpdatedAt } = actions; +export const releasedFeaturesReducer = reducer; diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index e20833166..9b6620679 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -21,6 +21,7 @@ import { } from './ducks/stagedAttachments'; import { userConfigReducer as userConfig, UserConfigState } from './ducks/userConfig'; import { userGroupReducer, UserGroupState } from './ducks/userGroups'; +import { releasedFeaturesReducer, ReleasedFeaturesState } from './ducks/releasedFeatures'; export type StateType = { search: SearchStateType; @@ -39,6 +40,7 @@ export type StateType = { settings: SettingsState; groups: GroupState; userGroups: UserGroupState; + releasedFeatures: ReleasedFeaturesState; }; const reducers = { @@ -58,6 +60,7 @@ const reducers = { settings: settingsReducer, groups: groupReducer, userGroups: userGroupReducer, + releasedFeatures: releasedFeaturesReducer, }; // Making this work would require that our reducer signature supported AnyAction, not diff --git a/ts/state/selectors/index.ts b/ts/state/selectors/index.ts index f64511879..9e90331db 100644 --- a/ts/state/selectors/index.ts +++ b/ts/state/selectors/index.ts @@ -9,6 +9,7 @@ import * as StagedAttachmentSelectors from './stagedAttachments'; import * as ThemeSelectors from './theme'; import * as UserSelectors from './user'; import * as UserConfigSelectors from './userConfig'; +import * as ReleasedFeaturesSelectors from './releasedFeatures'; export { CallSelectors, @@ -22,6 +23,7 @@ export { ThemeSelectors, UserConfigSelectors, UserSelectors, + ReleasedFeaturesSelectors, }; export * from './messages'; diff --git a/ts/state/selectors/releasedFeatures.ts b/ts/state/selectors/releasedFeatures.ts new file mode 100644 index 000000000..d21b3c7d7 --- /dev/null +++ b/ts/state/selectors/releasedFeatures.ts @@ -0,0 +1,21 @@ +import { useSelector } from 'react-redux'; +import { NetworkTime } from '../../util/NetworkTime'; +import { LEGACY_GROUP_DEPRECATED_TIMESTAMP_MS } from '../ducks/releasedFeatures'; + + +export const areLegacyGroupsDeprecatedYet = (): boolean => { + const theyAreDeprecated = NetworkTime.now() >= LEGACY_GROUP_DEPRECATED_TIMESTAMP_MS; + + return window.sessionFeatureFlags.forceLegacyGroupsDeprecated || theyAreDeprecated; +}; + +export function areLegacyGroupsDeprecatedYetOutsideRedux() { + if (!window.inboxStore) { + return false; + } + return areLegacyGroupsDeprecatedYet(); +} + +export function useAreLegacyGroupsDeprecatedYet() { + return useSelector(areLegacyGroupsDeprecatedYet); +} diff --git a/ts/state/selectors/selectedConversation.ts b/ts/state/selectors/selectedConversation.ts index 7d790ca1b..8e794fc62 100644 --- a/ts/state/selectors/selectedConversation.ts +++ b/ts/state/selectors/selectedConversation.ts @@ -18,6 +18,7 @@ import { import { getLibMembersPubkeys, useLibGroupName } from './groups'; import { getCanWrite, getModerators, getSubscriberCount } from './sogsRoomInfo'; import { getLibGroupDestroyed, getLibGroupKicked, useLibGroupDestroyed } from './userGroups'; +import { getDisableLegacyGroupDeprecatedActions } from '../../hooks/useRefreshReleasedFeaturesTimestamp'; const getIsSelectedPrivate = (state: StateType): boolean => { return Boolean(getSelectedConversation(state)?.isPrivate) || false; @@ -74,13 +75,20 @@ export function getSelectedCanWrite(state: StateType) { const isBlindedAndDisabledMsgRequests = getSelectedBlindedDisabledMsgRequests(state); // true if isPrivate, blinded and explicitly disabled msgreq - return !( - isBlocked || - isKickedFromGroup || - isSelectedGroupKicked || - isSelectedGroupDestroyed || - readOnlySogs || - isBlindedAndDisabledMsgRequests + const disabledLegacyGroupWrite = getDisableLegacyGroupDeprecatedActions( + state, + selectedConvoPubkey + ); + + return ( + !( + isBlocked || + isKickedFromGroup || + isSelectedGroupKicked || + isSelectedGroupDestroyed || + readOnlySogs || + isBlindedAndDisabledMsgRequests + ) && !disabledLegacyGroupWrite ); } diff --git a/ts/util/releaseFeature.ts b/ts/util/releaseFeature.ts index 76be91603..7f24e48c9 100644 --- a/ts/util/releaseFeature.ts +++ b/ts/util/releaseFeature.ts @@ -109,6 +109,7 @@ function isDisappearMessageV2FeatureReleasedCached(): boolean { return !!isDisappearingMessageFeatureReleased; } + export const ReleasedFeatures = { checkIsUserConfigFeatureReleased, checkIsDisappearMessageV2FeatureReleased, diff --git a/ts/window.d.ts b/ts/window.d.ts index 11c98e329..cb91d9317 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -103,6 +103,7 @@ declare global { useTestNet: boolean; useClosedGroupV2: boolean; useClosedGroupV2QAButtons: boolean; + forceLegacyGroupsDeprecated: boolean; replaceLocalizedStringsWithKeys: boolean; debug: { debugLogging: boolean;