Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/i18n/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@
"currentVersion": "Current version",
"newVersion": "New version",
"install": "Install update",
"openStore": {
"mas": "Open App Store",
"windows": "Open Microsoft Store",
"snap": "Open Snap Store",
"flatpak": "Open Flathub"
},
"skip": "Skip this version"
},
"updateInstallLater": {
Expand Down
2 changes: 2 additions & 0 deletions src/store/rootReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
updateDownloadStatus,
updateError,
updateChannel,
updateStore,
} from '../updates/reducers';

export const rootReducer = combineReducers({
Expand Down Expand Up @@ -142,6 +143,7 @@ export const rootReducer = combineReducers({
isDetailedEventsLoggingEnabled,
isVerboseOutlookLoggingEnabled,
updateChannel,
updateStore,
screenCaptureFallbackForced,
isVideoCallDevtoolsAutoOpenEnabled,
isPresenceDisconnectionSimulated,
Expand Down
33 changes: 33 additions & 0 deletions src/ui/components/SettingsView/features/CheckForUpdates.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,37 @@ describe('CheckForUpdates', () => {
).not.toBeInTheDocument();
expect(invokeMock).not.toHaveBeenCalled();
});

it('renders nothing when updates are not allowed in this build', () => {
renderWithStore(<CheckForUpdates />, {
preloadedState: { ...preloadedState, isUpdatingAllowed: false },
});

expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it('renders nothing when updates are disabled', () => {
renderWithStore(<CheckForUpdates />, {
preloadedState: { ...preloadedState, isUpdatingEnabled: false },
});

expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it.each(['mas', 'windows', 'snap', 'flatpak'] as const)(
'still renders the check button for a %s build despite isUpdatingAllowed being false',
(store) => {
renderWithStore(<CheckForUpdates />, {
preloadedState: {
...preloadedState,
isUpdatingAllowed: false,
updateStore: store,
},
});

expect(
screen.getByRole('button', { name: 'dialog.about.checkUpdates' })
).toBeInTheDocument();
}
);
});
8 changes: 7 additions & 1 deletion src/ui/components/SettingsView/features/CheckForUpdates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ export const CheckForUpdates = (props: CheckForUpdatesProps) => {
({ newUpdateVersion }: RootState) => newUpdateVersion
);
const updateError = useSelector(({ updateError }: RootState) => updateError);
const updateStore = useSelector(({ updateStore }: RootState) => updateStore);
const isStoreUpdate = updateStore !== null;

const [resultMessage, setResultMessage] = useState<string | null>(null);

Expand Down Expand Up @@ -132,7 +134,11 @@ export const CheckForUpdates = (props: CheckForUpdatesProps) => {

const fieldId = useId();

if (!isUpdatingAllowed || !isUpdatingEnabled) return null;
// Store builds cannot use electron-updater (isUpdatingAllowed is false for
// mas/windows there on purpose), but they still get a user-initiated check
// via the store's own lookup/page. Store builds have their own check
// listener despite isUpdatingAllowed being false, so they stay exempt.
if (!isStoreUpdate && (!isUpdatingAllowed || !isUpdatingEnabled)) return null;

return (
<ToggleField
Expand Down
41 changes: 41 additions & 0 deletions src/ui/components/TopBar/UpdateLabel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
UPDATES_CHECK_FEEDBACK_DISMISSED,
UPDATES_DOWNLOAD_REQUESTED,
UPDATES_INSTALL_REQUESTED,
UPDATES_OPEN_STORE_PAGE_REQUESTED,
UPDATES_PANEL_TOGGLED,
UPDATES_SKIP_REQUESTED,
} from '../../../updates/actions';
Expand Down Expand Up @@ -123,6 +124,30 @@ describe('UpdateLabel', () => {
});
});

it.each([
['mas', 'dialog.update.openStore.mas'],
['windows', 'dialog.update.openStore.windows'],
['snap', 'dialog.update.openStore.snap'],
['flatpak', 'dialog.update.openStore.flatpak'],
] as const)(
'opens the store page instead of downloading on a %s build',
async (store, labelKey) => {
const user = userEvent.setup();
renderWithStore(<UpdateLabel />, {
preloadedState: openState({ updateStore: store }),
});

await user.click(screen.getByText(labelKey));

expect(mockDispatch).toHaveBeenCalledWith({
type: UPDATES_OPEN_STORE_PAGE_REQUESTED,
});
expect(mockDispatch).not.toHaveBeenCalledWith({
type: UPDATES_DOWNLOAD_REQUESTED,
});
}
);

it('skips the version from the panel skip action', async () => {
const user = userEvent.setup();
renderWithStore(<UpdateLabel />, { preloadedState: openState() });
Expand Down Expand Up @@ -353,6 +378,22 @@ describe('UpdateLabel', () => {
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});

it.each(['mas', 'windows', 'snap', 'flatpak'] as const)(
'still shows checking feedback for a %s build despite isUpdatingAllowed being false',
(store) => {
renderWithStore(<UpdateLabel />, {
preloadedState: checkState('checking', {
isUpdatingAllowed: false,
updateStore: store,
}),
});

expect(screen.getByRole('button')).toHaveTextContent(
'tabBar.update.checking'
);
}
);

it('prefers the available-update pill over check feedback', () => {
renderWithStore(<UpdateLabel />, {
preloadedState: buildState({ updateCheckStatus: 'checking' }),
Expand Down
31 changes: 27 additions & 4 deletions src/ui/components/TopBar/UpdateLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,19 @@ import {
UPDATES_CHECK_FEEDBACK_DISMISSED,
UPDATES_DOWNLOAD_REQUESTED,
UPDATES_INSTALL_REQUESTED,
UPDATES_OPEN_STORE_PAGE_REQUESTED,
UPDATES_PANEL_TOGGLED,
UPDATES_SKIP_REQUESTED,
} from '../../../updates/actions';
import type { UpdateStore } from '../../../updates/common';

/** i18n key for the primary panel action's label, per store. */
const OPEN_STORE_PAGE_LABEL_KEYS: Record<Exclude<UpdateStore, null>, string> = {
mas: 'dialog.update.openStore.mas',
windows: 'dialog.update.openStore.windows',
snap: 'dialog.update.openStore.snap',
flatpak: 'dialog.update.openStore.flatpak',
};

type LabelVariant = 'primary' | 'success' | 'danger';

Expand Down Expand Up @@ -195,6 +205,8 @@ export const UpdateLabel = () => {
const isUpdatingEnabled = useSelector(
({ isUpdatingEnabled }: RootState) => isUpdatingEnabled
);
const updateStore = useSelector(({ updateStore }: RootState) => updateStore);
const isStoreUpdate = updateStore !== null;

const reference = useRef<HTMLButtonElement>(null);
const target = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -252,10 +264,10 @@ export const UpdateLabel = () => {
if (!newUpdateVersion) {
// In builds that cannot self-update the check listener is never
// registered, so a requested check would sit at "checking" forever —
// show nothing instead.
// show nothing instead. Store builds have their own check listener
// despite isUpdatingAllowed being false, so they stay exempt.
if (
!isUpdatingAllowed ||
!isUpdatingEnabled ||
(!isStoreUpdate && (!isUpdatingAllowed || !isUpdatingEnabled)) ||
updateCheckStatus === 'idle'
) {
return null;
Expand Down Expand Up @@ -337,6 +349,15 @@ export const UpdateLabel = () => {

const handleInstallClick = (): void => {
toggle(false);

if (isStoreUpdate) {
// Nothing to download on this build — hand off to the store's page
// via a distinct action, so the download-status reducers (which see
// every FSA) never flip to "downloading".
dispatch({ type: UPDATES_OPEN_STORE_PAGE_REQUESTED });
return;
}

dispatch({ type: UPDATES_DOWNLOAD_REQUESTED });
};

Expand Down Expand Up @@ -433,7 +454,9 @@ export const UpdateLabel = () => {
primary
onClick={handleInstallClick}
>
{t('dialog.update.install')}
{updateStore
? t(OPEN_STORE_PAGE_LABEL_KEYS[updateStore])
: t('dialog.update.install')}
</Button>
</ButtonGroup>
</Box>
Expand Down
110 changes: 106 additions & 4 deletions src/ui/main/menuBar.main.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,10 +429,15 @@ describe('ui/main/menuBar', () => {
expect(serverB?.checked).toBe(false);
});

it('always includes checkForUpdates and dispatches on click', async () => {
it('includes checkForUpdates and dispatches on click when updating is allowed and enabled', async () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { dispatch } = require('../../store');
const state = createState({ isDeveloperModeEnabled: false });
const state = createState({
isDeveloperModeEnabled: false,
isUpdatingAllowed: true,
isUpdatingEnabled: true,
updateStore: null,
});
const template = selectServerSwitcherMenuTemplate(state);

const checkForUpdates = findMenu(template, 'checkForUpdates');
Expand All @@ -443,6 +448,45 @@ describe('ui/main/menuBar', () => {
});
});

it('includes checkForUpdates for a store build even when isUpdatingAllowed is false', () => {
const state = createState({
isDeveloperModeEnabled: false,
isUpdatingAllowed: false,
isUpdatingEnabled: false,
updateStore: 'mas',
});
const template = selectServerSwitcherMenuTemplate(state);
const ids = template.map((item) => item.id);

expect(ids).toContain('checkForUpdates');
});

it('omits checkForUpdates when updating is neither allowed nor store-distributed (e.g. Linux deb/rpm/tar.gz)', () => {
const state = createState({
isDeveloperModeEnabled: false,
isUpdatingAllowed: false,
isUpdatingEnabled: true,
updateStore: null,
});
const template = selectServerSwitcherMenuTemplate(state);
const ids = template.map((item) => item.id);

expect(ids).not.toContain('checkForUpdates');
});

it('omits checkForUpdates when updating is allowed but admin-disabled (isUpdatingEnabled: false) on a non-store build', () => {
const state = createState({
isDeveloperModeEnabled: false,
isUpdatingAllowed: true,
isUpdatingEnabled: false,
updateStore: null,
});
const template = selectServerSwitcherMenuTemplate(state);
const ids = template.map((item) => item.id);

expect(ids).not.toContain('checkForUpdates');
});

it('omits simulate items when developer mode is off', () => {
const state = createState({ isDeveloperModeEnabled: false });
const template = selectServerSwitcherMenuTemplate(state);
Expand All @@ -464,7 +508,12 @@ describe('ui/main/menuBar', () => {
focus: jest.fn(),
});

const state = createState({ isDeveloperModeEnabled: true });
const state = createState({
isDeveloperModeEnabled: true,
isUpdatingAllowed: true,
isUpdatingEnabled: true,
updateStore: null,
});
const template = selectServerSwitcherMenuTemplate(state);
const ids = template.map((item) => item.id);

Expand Down Expand Up @@ -536,7 +585,12 @@ describe('ui/main/menuBar', () => {
});

it('lists simulate items right after checkForUpdates when developer mode is on', () => {
const state = createState({ isDeveloperModeEnabled: true });
const state = createState({
isDeveloperModeEnabled: true,
isUpdatingAllowed: true,
isUpdatingEnabled: true,
updateStore: null,
});
const template = selectAppMenuPopupTemplate(state);
const ids = template.map((item) => item.id);

Expand All @@ -547,6 +601,54 @@ describe('ui/main/menuBar', () => {
expect(ids[checkForUpdatesIndex + 3]).toBe('simulateDownload');
expect(ids[checkForUpdatesIndex + 4]).toBe('simulateDisconnected');
});

it('includes checkForUpdates when updating is allowed and enabled', () => {
const state = createState({
isUpdatingAllowed: true,
isUpdatingEnabled: true,
updateStore: null,
});
const template = selectAppMenuPopupTemplate(state);
const ids = template.map((item) => item.id);

expect(ids).toContain('checkForUpdates');
});

it('includes checkForUpdates for a store build even when isUpdatingAllowed is false', () => {
const state = createState({
isUpdatingAllowed: false,
isUpdatingEnabled: false,
updateStore: 'mas',
});
const template = selectAppMenuPopupTemplate(state);
const ids = template.map((item) => item.id);

expect(ids).toContain('checkForUpdates');
});

it('omits checkForUpdates when updating is neither allowed nor store-distributed (e.g. Linux deb/rpm/tar.gz)', () => {
const state = createState({
isUpdatingAllowed: false,
isUpdatingEnabled: true,
updateStore: null,
});
const template = selectAppMenuPopupTemplate(state);
const ids = template.map((item) => item.id);

expect(ids).not.toContain('checkForUpdates');
});

it('omits checkForUpdates when updating is allowed but admin-disabled (isUpdatingEnabled: false) on a non-store build', () => {
const state = createState({
isUpdatingAllowed: true,
isUpdatingEnabled: false,
updateStore: null,
});
const template = selectAppMenuPopupTemplate(state);
const ids = template.map((item) => item.id);

expect(ids).not.toContain('checkForUpdates');
});
});
});
});
Expand Down
Loading
Loading