Skip to content

Commit 43b76f1

Browse files
authored
Merge pull request #21355 from wireapp/feature-flags
Make startup feature toggles operable from the Developer Menu [WPB-22420]
2 parents 37646f6 + 4846603 commit 43b76f1

8 files changed

Lines changed: 309 additions & 15 deletions

File tree

apps/webapp/src/script/components/ConfigToolbar/ConfigToolbar.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,21 @@ import {Button, Input, Switch} from '@wireapp/react-ui-kit';
2626

2727
import {ConversationState} from 'Repositories/conversation/ConversationState';
2828
import {Config, Configuration} from 'src/script/Config';
29+
import {StartupFeatureToggleName, startupFeatureToggleNames} from 'src/script/featureToggles/startupFeatureToggleNames';
30+
import {updateLocationSearchForStartupFeatureToggle} from 'src/script/featureToggles/startupFeatureToggleQueryParameters';
2931
import {useClickOutside} from 'src/script/hooks/useClickOutside';
3032
import {useApplicationContext} from 'src/script/page/RootProvider';
3133
import {CoreCryptoLogLevel} from 'Util/debugUtil';
3234

3335
import {wrapperStyles} from './ConfigToolbar.styles';
3436

37+
export function createLocationUrl(pathname: string, search: string, hash: string): string {
38+
return `${pathname}${search}${hash}`;
39+
}
40+
3541
export function ConfigToolbar() {
36-
const {fireAndForgetInvoker} = useApplicationContext();
42+
const {fireAndForgetInvoker, applicationNavigation, isFeatureToggleEnabled} = useApplicationContext();
43+
const alphabeticallySortedStartupFeatureToggleNames = [...startupFeatureToggleNames].sort();
3744
const [showConfig, setShowConfig] = useState(false);
3845
const [isResettingMLSConversation, setIsResettingMLSConversation] = useState(false);
3946
const [isGzipEnabled, setIsGzipEnabled] = useState(window.wire?.app.debug?.isGzippingEnabled() ?? false);
@@ -303,6 +310,52 @@ export function ConfigToolbar() {
303310
);
304311
};
305312

313+
function reloadApplicationForStartupFeatureToggle(
314+
featureToggleName: StartupFeatureToggleName,
315+
shouldEnableFeatureToggle: boolean,
316+
): void {
317+
const locationSearch = applicationNavigation.currentSearch;
318+
const nextLocationSearch = updateLocationSearchForStartupFeatureToggle({
319+
locationSearch,
320+
featureToggleName,
321+
shouldEnableFeatureToggle,
322+
});
323+
const locationPathname = applicationNavigation.currentPathname;
324+
const locationHash = applicationNavigation.currentHash;
325+
const nextLocationUrl = createLocationUrl(locationPathname, nextLocationSearch, locationHash);
326+
327+
applicationNavigation.navigateTo(nextLocationUrl);
328+
}
329+
330+
function renderStartupFeatureToggleCheckboxList() {
331+
return (
332+
<fieldset style={{margin: 0, border: 0, padding: 0}}>
333+
<legend style={{fontWeight: 'bold', marginBottom: '8px'}}>Startup Feature Toggles</legend>
334+
<ul style={{listStyle: 'none', margin: 0, padding: 0}}>
335+
{alphabeticallySortedStartupFeatureToggleNames.map(featureToggleName => {
336+
const featureToggleCheckboxIdentifier = `startup-feature-toggle-checkbox-${featureToggleName}`;
337+
338+
return (
339+
<li key={featureToggleName} style={{marginBottom: '10px'}}>
340+
<label htmlFor={featureToggleCheckboxIdentifier} style={{display: 'block'}}>
341+
<input
342+
id={featureToggleCheckboxIdentifier}
343+
type="checkbox"
344+
checked={isFeatureToggleEnabled(featureToggleName)}
345+
onChange={event => {
346+
reloadApplicationForStartupFeatureToggle(featureToggleName, event.currentTarget.checked);
347+
}}
348+
/>
349+
{` ${featureToggleName}`}
350+
</label>
351+
</li>
352+
);
353+
})}
354+
</ul>
355+
</fieldset>
356+
);
357+
}
358+
306359
const resetMLSConversation = async () => {
307360
setIsResettingMLSConversation(true);
308361
try {
@@ -359,6 +412,10 @@ export function ConfigToolbar() {
359412

360413
<hr />
361414

415+
<div>{renderStartupFeatureToggleCheckboxList()}</div>
416+
417+
<hr />
418+
362419
<h3>Message Automation</h3>
363420
<Input
364421
type="text"
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Wire
3+
* Copyright (C) 2026 Wire Swiss GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see http://www.gnu.org/licenses/.
17+
*
18+
*/
19+
20+
import {
21+
applockRefactoredFeatureToggleName,
22+
reliableWebsocketConnectionFeatureToggleName,
23+
} from './startupFeatureToggleNames';
24+
import {startupFeatureToggleQueryParameterName} from './startupFeatureToggles';
25+
import {updateLocationSearchForStartupFeatureToggle} from './startupFeatureToggleQueryParameters';
26+
27+
describe('updateLocationSearchForStartupFeatureToggle', () => {
28+
it('adds a feature toggle to an existing query string and preserves unrelated parameters', () => {
29+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
30+
locationSearch: '?foo=bar',
31+
featureToggleName: reliableWebsocketConnectionFeatureToggleName,
32+
shouldEnableFeatureToggle: true,
33+
});
34+
35+
expect(updatedLocationSearch).toBe(
36+
`?foo=bar&${startupFeatureToggleQueryParameterName}=${reliableWebsocketConnectionFeatureToggleName}`,
37+
);
38+
});
39+
40+
it('removes a feature toggle and preserves other enabled toggles', () => {
41+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
42+
locationSearch: `?${startupFeatureToggleQueryParameterName}=${reliableWebsocketConnectionFeatureToggleName},${applockRefactoredFeatureToggleName}`,
43+
featureToggleName: reliableWebsocketConnectionFeatureToggleName,
44+
shouldEnableFeatureToggle: false,
45+
});
46+
47+
expect(updatedLocationSearch).toBe(
48+
`?${startupFeatureToggleQueryParameterName}=${applockRefactoredFeatureToggleName}`,
49+
);
50+
});
51+
52+
it('removes only the startup feature parameter when the last feature toggle is disabled', () => {
53+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
54+
locationSearch: `?${startupFeatureToggleQueryParameterName}=${reliableWebsocketConnectionFeatureToggleName}&foo=bar`,
55+
featureToggleName: reliableWebsocketConnectionFeatureToggleName,
56+
shouldEnableFeatureToggle: false,
57+
});
58+
59+
expect(updatedLocationSearch).toBe('?foo=bar');
60+
});
61+
62+
it('returns an empty search string when the last query parameter is removed', () => {
63+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
64+
locationSearch: `?${startupFeatureToggleQueryParameterName}=${reliableWebsocketConnectionFeatureToggleName}`,
65+
featureToggleName: reliableWebsocketConnectionFeatureToggleName,
66+
shouldEnableFeatureToggle: false,
67+
});
68+
69+
expect(updatedLocationSearch).toBe('');
70+
});
71+
72+
it('ignores unknown feature names already present in query parameter when enabling a new toggle', () => {
73+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
74+
locationSearch: `?${startupFeatureToggleQueryParameterName}=unknown-feature`,
75+
featureToggleName: reliableWebsocketConnectionFeatureToggleName,
76+
shouldEnableFeatureToggle: true,
77+
});
78+
79+
expect(updatedLocationSearch).toBe(
80+
`?${startupFeatureToggleQueryParameterName}=${reliableWebsocketConnectionFeatureToggleName}`,
81+
);
82+
});
83+
84+
it('deduplicates feature toggles when enabling an already enabled toggle', () => {
85+
const updatedLocationSearch = updateLocationSearchForStartupFeatureToggle({
86+
locationSearch: `?${startupFeatureToggleQueryParameterName}=${reliableWebsocketConnectionFeatureToggleName},${reliableWebsocketConnectionFeatureToggleName}`,
87+
featureToggleName: reliableWebsocketConnectionFeatureToggleName,
88+
shouldEnableFeatureToggle: true,
89+
});
90+
91+
expect(updatedLocationSearch).toBe(
92+
`?${startupFeatureToggleQueryParameterName}=${reliableWebsocketConnectionFeatureToggleName}`,
93+
);
94+
});
95+
});
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Wire
3+
* Copyright (C) 2026 Wire Swiss GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see http://www.gnu.org/licenses/.
17+
*
18+
*/
19+
20+
import {Maybe} from 'true-myth';
21+
22+
import {StartupFeatureToggleName, startupFeatureToggleNames} from './startupFeatureToggleNames';
23+
import {
24+
createStartupFeatureTogglesFromLocationSearch,
25+
startupFeatureToggleQueryParameterName,
26+
} from './startupFeatureToggles';
27+
28+
type UpdateStartupFeatureToggleLocationSearchInput = {
29+
readonly locationSearch: string;
30+
readonly featureToggleName: StartupFeatureToggleName;
31+
readonly shouldEnableFeatureToggle: boolean;
32+
};
33+
34+
function toOrderedEnabledFeatureToggleNames(
35+
enabledFeatureToggleNameSet: ReadonlySet<StartupFeatureToggleName>,
36+
): readonly StartupFeatureToggleName[] {
37+
return startupFeatureToggleNames.filter(featureToggleName => {
38+
return enabledFeatureToggleNameSet.has(featureToggleName);
39+
});
40+
}
41+
42+
function readEnabledFeatureToggleNameSetFromLocationSearch(
43+
locationSearch: string,
44+
): ReadonlySet<StartupFeatureToggleName> {
45+
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch(locationSearch);
46+
return new Set(startupFeatureToggles.enabledFeatureToggleNames);
47+
}
48+
49+
function toUpdatedEnabledFeatureToggleNameSet(
50+
enabledFeatureToggleNameSet: ReadonlySet<StartupFeatureToggleName>,
51+
featureToggleName: StartupFeatureToggleName,
52+
shouldEnableFeatureToggle: boolean,
53+
): ReadonlySet<StartupFeatureToggleName> {
54+
const updatedEnabledFeatureToggleNameSet = new Set(enabledFeatureToggleNameSet);
55+
56+
if (shouldEnableFeatureToggle) {
57+
updatedEnabledFeatureToggleNameSet.add(featureToggleName);
58+
} else {
59+
updatedEnabledFeatureToggleNameSet.delete(featureToggleName);
60+
}
61+
62+
return updatedEnabledFeatureToggleNameSet;
63+
}
64+
65+
function serializeEnabledFeatureToggleNames(
66+
enabledFeatureToggleNameSet: ReadonlySet<StartupFeatureToggleName>,
67+
): Maybe<string> {
68+
const orderedEnabledFeatureToggleNames = toOrderedEnabledFeatureToggleNames(enabledFeatureToggleNameSet);
69+
70+
if (orderedEnabledFeatureToggleNames.length === 0) {
71+
return Maybe.nothing<string>();
72+
}
73+
74+
return Maybe.just(orderedEnabledFeatureToggleNames.join(','));
75+
}
76+
77+
export function updateLocationSearchForStartupFeatureToggle(
78+
input: UpdateStartupFeatureToggleLocationSearchInput,
79+
): string {
80+
const {locationSearch, featureToggleName, shouldEnableFeatureToggle} = input;
81+
const enabledFeatureToggleNameSet = readEnabledFeatureToggleNameSetFromLocationSearch(locationSearch);
82+
const updatedEnabledFeatureToggleNameSet = toUpdatedEnabledFeatureToggleNameSet(
83+
enabledFeatureToggleNameSet,
84+
featureToggleName,
85+
shouldEnableFeatureToggle,
86+
);
87+
88+
const queryParameters = new URLSearchParams(locationSearch);
89+
const serializedEnabledFeatureToggleNames = serializeEnabledFeatureToggleNames(updatedEnabledFeatureToggleNameSet);
90+
91+
if (serializedEnabledFeatureToggleNames.isNothing) {
92+
queryParameters.delete(startupFeatureToggleQueryParameterName);
93+
} else {
94+
queryParameters.set(startupFeatureToggleQueryParameterName, serializedEnabledFeatureToggleNames.value);
95+
}
96+
97+
const serializedQueryParameters = queryParameters.toString();
98+
return Maybe.of(serializedQueryParameters)
99+
.andThen((queryParameterString): Maybe<string> => {
100+
if (queryParameterString.length === 0) {
101+
return Maybe.nothing<string>();
102+
}
103+
104+
return Maybe.just(queryParameterString);
105+
})
106+
.map((queryParameterString): string => {
107+
return `?${queryParameterString}`;
108+
})
109+
.unwrapOr('');
110+
}

apps/webapp/src/script/featureToggles/startupFeatureToggles.test.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('startupFeatureToggles', function () {
4040
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch('?foo=bar');
4141

4242
expect(startupFeatureToggles.isFeatureToggleEnabled(reliableWebsocketConnectionFeatureToggleName)).toBe(false);
43-
expect(startupFeatureToggles.getEnabledFeatureToggleNames()).toEqual([]);
43+
expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([]);
4444
});
4545

4646
it('enables a whitelisted feature toggle when present in the query parameter', () => {
@@ -57,15 +57,15 @@ describe('startupFeatureToggles', function () {
5757
);
5858

5959
expect(startupFeatureToggles.isFeatureToggleEnabled(reliableWebsocketConnectionFeatureToggleName)).toBe(true);
60-
expect(startupFeatureToggles.getEnabledFeatureToggleNames()).not.toContain('unknown-feature');
60+
expect(startupFeatureToggles.enabledFeatureToggleNames).not.toContain('unknown-feature');
6161
});
6262

6363
it('ignores unknown feature toggles from the query parameter', () => {
6464
const startupFeatureToggles = createStartupFeatureTogglesFromLocationSearch(
6565
`?${startupFeatureToggleQueryParameterName}=unknown-feature`,
6666
);
6767

68-
expect(startupFeatureToggles.getEnabledFeatureToggleNames()).toEqual([]);
68+
expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([]);
6969
});
7070

7171
it('keeps only whitelisted feature toggles when known and unknown values are mixed', () => {
@@ -74,7 +74,7 @@ describe('startupFeatureToggles', function () {
7474
);
7575

7676
expect(startupFeatureToggles.isFeatureToggleEnabled(reliableWebsocketConnectionFeatureToggleName)).toBe(true);
77-
expect(startupFeatureToggles.getEnabledFeatureToggleNames()).not.toContain('unknown-feature');
77+
expect(startupFeatureToggles.enabledFeatureToggleNames).not.toContain('unknown-feature');
7878
});
7979

8080
it('enables the applock refactored feature toggle when present in the query parameter', () => {
@@ -107,9 +107,7 @@ describe('startupFeatureToggles', function () {
107107
);
108108

109109
expect(startupFeatureToggles.isFeatureToggleEnabled(reliableWebsocketConnectionFeatureToggleName)).toBe(true);
110-
expect(startupFeatureToggles.getEnabledFeatureToggleNames()).toEqual([
111-
reliableWebsocketConnectionFeatureToggleName,
112-
]);
110+
expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([reliableWebsocketConnectionFeatureToggleName]);
113111
});
114112

115113
it('ignores empty list entries in the feature toggle query parameter', () => {
@@ -118,9 +116,7 @@ describe('startupFeatureToggles', function () {
118116
);
119117

120118
expect(startupFeatureToggles.isFeatureToggleEnabled(reliableWebsocketConnectionFeatureToggleName)).toBe(true);
121-
expect(startupFeatureToggles.getEnabledFeatureToggleNames()).toEqual([
122-
reliableWebsocketConnectionFeatureToggleName,
123-
]);
119+
expect(startupFeatureToggles.enabledFeatureToggleNames).toEqual([reliableWebsocketConnectionFeatureToggleName]);
124120
});
125121

126122
it('treats feature toggle names as case-sensitive', () => {
@@ -130,7 +126,7 @@ describe('startupFeatureToggles', function () {
130126
);
131127

132128
expect(startupFeatureToggles.isFeatureToggleEnabled(reliableWebsocketConnectionFeatureToggleName)).toBe(false);
133-
expect(startupFeatureToggles.getEnabledFeatureToggleNames()).not.toContain(uppercaseFeatureToggleName);
129+
expect(startupFeatureToggles.enabledFeatureToggleNames).not.toContain(uppercaseFeatureToggleName);
134130
});
135131

136132
it('contains only whitelisted values in allowedStartupFeatureToggleNames', () => {

apps/webapp/src/script/featureToggles/startupFeatureToggles.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const allowedStartupFeatureToggleNameSet = new Set<StartupFeatureToggleName>(all
3030

3131
export type StartupFeatureToggles = {
3232
readonly isFeatureToggleEnabled: (featureToggleName: StartupFeatureToggleName) => boolean;
33-
readonly getEnabledFeatureToggleNames: () => readonly StartupFeatureToggleName[];
33+
readonly enabledFeatureToggleNames: readonly StartupFeatureToggleName[];
3434
};
3535

3636
function trimFeatureToggleName(featureToggleName: string): string {
@@ -68,11 +68,11 @@ export function createStartupFeatureTogglesFromLocationSearch(locationSearch: st
6868
);
6969

7070
return {
71-
isFeatureToggleEnabled: function isFeatureToggleEnabled(featureToggleName: StartupFeatureToggleName): boolean {
71+
isFeatureToggleEnabled(featureToggleName) {
7272
return enabledFeatureToggleNameSet.has(featureToggleName);
7373
},
7474

75-
getEnabledFeatureToggleNames: function getEnabledFeatureToggleNames(): readonly StartupFeatureToggleName[] {
75+
get enabledFeatureToggleNames() {
7676
return [...enabledFeatureToggleNameSet];
7777
},
7878
};

0 commit comments

Comments
 (0)