[MM-61717] Refresh Settings Modal without Bootstrap (#3337)

* [MM-61717] Refresh Settings Modal without Bootstrap

* Fix i18n

* Couple small bug fixes

* E2E test updates

* Fix linux tests

* PR feedback

* PR feedback

* PR feedback

* Fix the border opacity and height

* PR feedback

* PR feedback 2
This commit is contained in:
Devin Binnie
2025-02-27 15:51:49 -05:00
committed by GitHub
parent a4019ddd72
commit 5d7374971c
40 changed files with 2294 additions and 1669 deletions

View File

@@ -1,56 +0,0 @@
// Copyright (c) 2015-2016 Yuya Ochiai
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-bootstrap';
import type {IntlShape} from 'react-intl';
import {useIntl} from 'react-intl';
const baseClassName = 'AutoSaveIndicator';
const leaveClassName = `${baseClassName}-Leave`;
export enum SavingState {
SAVING_STATE_SAVING = 'saving',
SAVING_STATE_SAVED = 'saved',
SAVING_STATE_ERROR = 'error',
SAVING_STATE_DONE = 'done',
}
function getClassNameAndMessage(intl: IntlShape, savingState: SavingState, errorMessage?: React.ReactNode) {
switch (savingState) {
case SavingState.SAVING_STATE_SAVING:
return {className: baseClassName, message: intl.formatMessage({id: 'renderer.components.autoSaveIndicator.saving', defaultMessage: 'Saving...'})};
case SavingState.SAVING_STATE_SAVED:
return {className: baseClassName, message: intl.formatMessage({id: 'renderer.components.autoSaveIndicator.saved', defaultMessage: 'Saved'})};
case SavingState.SAVING_STATE_ERROR:
return {className: `${baseClassName}`, message: errorMessage};
case SavingState.SAVING_STATE_DONE:
return {className: `${baseClassName} ${leaveClassName}`, message: intl.formatMessage({id: 'renderer.components.autoSaveIndicator.saved', defaultMessage: 'Saved'})};
default:
return {className: `${baseClassName} ${leaveClassName}`, message: ''};
}
}
type Props = {
id?: string;
savingState: SavingState;
errorMessage?: React.ReactNode;
};
const AutoSaveIndicator: React.FC<Props> = (props: Props) => {
const intl = useIntl();
const {savingState, errorMessage, ...rest} = props;
const {className, message} = getClassNameAndMessage(intl, savingState, errorMessage);
return (
<Alert
className={className}
{...rest}
variant={savingState === 'error' ? 'danger' : 'info'}
>
{message}
</Alert>
);
};
export default AutoSaveIndicator;

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
const ServerSmallImage = () => (
<svg
width='85'
height='75'
viewBox='0 0 85 75'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<rect
x='0.5'
y='0.441162'
width='84'
height='22.7294'
rx='2.96471'
fill='var(--center-channel-color)'
fillOpacity='0.12'
/>
<path
opacity='0.48'
d='M80.5472 4.39417H4.45312V19.2177H80.5472V4.39417Z'
stroke='var(--center-channel-color)'
strokeOpacity='0.75'
strokeWidth='0.988235'
strokeLinecap='round'
/>
<path
d='M21.2528 15.2646C22.8902 15.2646 24.2175 13.9373 24.2175 12.2999C24.2175 10.6626 22.8902 9.33521 21.2528 9.33521C19.6154 9.33521 18.2881 10.6626 18.2881 12.2999C18.2881 13.9373 19.6154 15.2646 21.2528 15.2646Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<path
d='M30.1463 15.2646C31.7837 15.2646 33.1111 13.9373 33.1111 12.2999C33.1111 10.6626 31.7837 9.33521 30.1463 9.33521C28.509 9.33521 27.1816 10.6626 27.1816 12.2999C27.1816 13.9373 28.509 15.2646 30.1463 15.2646Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<path
opacity='0.5'
d='M12.3583 15.2646C13.9956 15.2646 15.323 13.9373 15.323 12.2999C15.323 10.6626 13.9956 9.33521 12.3583 9.33521C10.7209 9.33521 9.39355 10.6626 9.39355 12.2999C9.39355 13.9373 10.7209 15.2646 12.3583 15.2646Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<rect
x='0.5'
y='26.1353'
width='84'
height='22.7294'
rx='2.96471'
fill='var(--center-channel-color)'
fillOpacity='0.12'
/>
<path
opacity='0.48'
d='M80.5472 30.0883H4.45312V44.9118H80.5472V30.0883Z'
stroke='var(--center-channel-color)'
strokeOpacity='0.75'
strokeWidth='0.988235'
strokeLinecap='round'
/>
<path
d='M21.2528 40.9588C22.8902 40.9588 24.2175 39.6315 24.2175 37.9941C24.2175 36.3568 22.8902 35.0294 21.2528 35.0294C19.6154 35.0294 18.2881 36.3568 18.2881 37.9941C18.2881 39.6315 19.6154 40.9588 21.2528 40.9588Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<path
d='M30.1463 40.9588C31.7837 40.9588 33.1111 39.6315 33.1111 37.9941C33.1111 36.3568 31.7837 35.0294 30.1463 35.0294C28.509 35.0294 27.1816 36.3568 27.1816 37.9941C27.1816 39.6315 28.509 40.9588 30.1463 40.9588Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<path
opacity='0.5'
d='M12.3583 40.9588C13.9956 40.9588 15.323 39.6315 15.323 37.9941C15.323 36.3568 13.9956 35.0294 12.3583 35.0294C10.7209 35.0294 9.39355 36.3568 9.39355 37.9941C9.39355 39.6315 10.7209 40.9588 12.3583 40.9588Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<rect
x='0.5'
y='51.8295'
width='84'
height='22.7294'
rx='2.96471'
fill='var(--center-channel-color)'
fillOpacity='0.12'
/>
<path
opacity='0.48'
d='M80.5472 55.7823H4.45312V70.6059H80.5472V55.7823Z'
stroke='var(--center-channel-color)'
strokeOpacity='0.75'
strokeWidth='0.988235'
strokeLinecap='round'
/>
<path
d='M21.2528 66.6529C22.8902 66.6529 24.2175 65.3256 24.2175 63.6882C24.2175 62.0509 22.8902 60.7235 21.2528 60.7235C19.6154 60.7235 18.2881 62.0509 18.2881 63.6882C18.2881 65.3256 19.6154 66.6529 21.2528 66.6529Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<path
d='M30.1463 66.6529C31.7837 66.6529 33.1111 65.3256 33.1111 63.6882C33.1111 62.0509 31.7837 60.7235 30.1463 60.7235C28.509 60.7235 27.1816 62.0509 27.1816 63.6882C27.1816 65.3256 28.509 66.6529 30.1463 66.6529Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
<path
opacity='0.5'
d='M12.3583 66.6529C13.9956 66.6529 15.323 65.3256 15.323 63.6882C15.323 62.0509 13.9956 60.7235 12.3583 60.7235C10.7209 60.7235 9.39355 62.0509 9.39355 63.6882C9.39355 65.3256 10.7209 66.6529 12.3583 66.6529Z'
fill='var(--center-channel-color)'
fillOpacity='0.56'
/>
</svg>
);
export default ServerSmallImage;

View File

@@ -78,6 +78,29 @@ export const Modal: React.FC<Props> = ({
const [showState, setShowState] = useState<boolean>();
const backdropRef = useRef<HTMLDivElement>(null);
const onClose = useCallback(async () => {
await onHide();
onExited();
}, [onExited]);
useEffect(() => {
const escListener = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
async function createEscListener() {
const uncloseable = await window.desktop.modals.isModalUncloseable();
if (!uncloseable) {
window.addEventListener('keydown', escListener);
}
}
createEscListener();
return () => {
window.removeEventListener('keydown', escListener);
};
}, [onClose]);
useEffect(() => {
setShowState(show ?? true);
}, [show]);
@@ -91,11 +114,6 @@ export const Modal: React.FC<Props> = ({
});
};
const onClose = useCallback(async () => {
await onHide();
onExited();
}, [onExited]);
const handleCancelClick = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
if (autoCloseOnCancelButton) {

View File

@@ -0,0 +1,106 @@
@use '../../css/css_variables';
.SettingsModal {
&.Modal_dialog {
width: 832px;
max-width: 832px;
}
> .Modal_content {
border: var(--border-light);
display: flex;
flex-direction: column;
> .Modal_body {
display: flex;
flex-direction: column;
flex: 1;
> .Modal__body {
display: flex;
flex: 1;
min-height: 475px;
.SettingsModal__sidebar {
flex: 0 0 auto;
width: 232px;
padding: 24px;
box-sizing: border-box;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
.SettingsModal__category {
border: none;
width: 100%;
padding: 8px 12px;
border-radius: var(--radius-s);
background: none;
color: rgba(var(--center-channel-color-rgb), 0.56);
display: flex;
align-items: center;
& + .SettingsModal__category {
margin-top: 8px;
}
&.selected {
background-color: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
}
&:hover:not(.selected) {
background-color: rgba(var(--center-channel-color-rgb), 0.04);
color: rgba(var(--center-channel-color-rgb), 0.8);
}
> span {
font-weight: 600;
line-height: 14px;
font-size: 14px;
margin-left: 8px;
}
> i {
font-size: 18px;
line-height: 18px;
&::before {
margin: 0;
}
}
}
}
.SettingsModal__content {
padding: 28px 32px;
border-left: var(--border-light);
width: 100%;
display: flex;
flex-direction: column;
> div + div {
margin-top: 24px;
}
}
}
}
}
.SettingsModal__saving {
padding: 8px 16px;
margin-right: 14px;
color: rgba(var(--center-channel-color-rgb), 0.56);
display: flex;
> span {
font-size: 12px;
line-height: 16px;
font-weight: 600;
white-space: nowrap;
}
> i {
font-size: 14.4px;
line-height: 14.4px;
}
}
}

View File

@@ -0,0 +1,72 @@
.CheckSetting {
.CheckSetting__heading {
font-size: 12px;
line-height: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.CheckSetting__content {
display: flex;
.CheckSetting__checkbox {
margin-right: 10px;
background: none;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.24);
border-radius: var(--radius-xs);
width: 16px;
height: 16px;
margin-top: 2px;
input {
appearance: none;
position: absolute;
}
> i {
display: block;
font-size: 0;
line-height: 16px;
position: relative;
right: -1px;
top: 2px;
width: 0;
height: 0;
border-radius: var(--radius-xs);
transition: all ease-in-out 0.175s;
background: none;
color: none;
&::before {
margin: 0;
}
}
&.checked {
border: 1px solid var(--button-bg);
> i {
right: 7px;
top: -2px;
width: 16px;
height: 16px;
font-size: 14.4px;
background: var(--button-bg);
color: var(--button-color);
}
}
}
.CheckSetting__label {
font-size: 14px;
line-height: 20px;
.CheckSetting__sublabel {
margin-top: 4px;
font-size: 12px;
line-height: 16px;
color: rgba(var(--center-channel-color-rgb), 0.76)
}
}
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useState} from 'react';
import './CheckSetting.scss';
export default function CheckSetting({
id,
onSave,
label,
heading,
subLabel,
...props
}: {
id: string;
onSave: (key: string, value: boolean) => void;
label: React.ReactNode;
value: boolean;
heading?: React.ReactNode;
subLabel?: React.ReactNode;
}) {
const [value, setValue] = useState(props.value);
const save = () => {
onSave(id, !value);
setValue(!value);
};
return (
<div
id={`CheckSetting_${id}`}
className='CheckSetting'
>
{heading && <div className='CheckSetting__heading'>{heading}</div>}
<div className='CheckSetting__content'>
<button
className={classNames('CheckSetting__checkbox', {checked: value})}
onClick={save}
role='checkbox'
aria-checked={value}
aria-labelledby={`checkSetting-${id}`}
>
<input
id={`checkSetting-${id}`}
defaultChecked={value}
type='checkbox'
tabIndex={-1}
disabled={true}
/>
<i className='icon-check'/>
</button>
<label
htmlFor={`checkSetting-${id}`}
className='CheckSetting__label'
>
{label}
{subLabel && <div className='CheckSetting__sublabel'>{subLabel}</div>}
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
.DownloadSetting {
padding-bottom: 24px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
.DownloadSetting__content {
display: flex;
margin-top: 16px;
.Input_container.disabled {
margin-top: 0;
input {
height: 34px;
font-size: 14px;
line-height: 20px;
}
}
button {
margin-left: 8px;
}
}
.DownloadSetting__heading {
margin: 0;
}
.DownloadSetting__label {
margin-top: 8px;
font-size: 12px;
line-height: 16px;
color: rgba(var(--center-channel-color-rgb), 0.64);
}
}
div + .DownloadSetting {
padding-top: 24px;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage} from 'react-intl';
import './DownloadSetting.scss';
import Input, {SIZE} from 'renderer/components/Input';
export default function DownloadSetting({
id,
onSave,
...props
}: {
id: string;
onSave: (key: string, value: string) => void;
label: React.ReactNode;
value: string;
}) {
const [value, setValue] = useState(props.value);
const selectDownloadLocation = async () => {
const newDownloadLocation = await window.desktop.getDownloadLocation(props.value);
if (!newDownloadLocation) {
return;
}
onSave(id, newDownloadLocation);
setValue(newDownloadLocation);
};
return (
<div className='DownloadSetting'>
<h3 className='DownloadSetting__heading'>
<FormattedMessage
id='renderer.components.settingsPage.downloadLocation'
defaultMessage='Download Location'
/>
</h3>
<div className='DownloadSetting__label'>
<FormattedMessage
id='renderer.components.settingsPage.downloadLocation.description'
defaultMessage='Specify the folder where files will download.'
/>
</div>
<div className='DownloadSetting__content'>
<Input
disabled={true}
value={value}
inputSize={SIZE.MEDIUM}
/>
<button
className='DownloadSetting__changeButton btn btn-tertiary'
id='saveDownloadLocation'
onClick={selectDownloadLocation}
>
<FormattedMessage
id='label.change'
defaultMessage='Change'
/>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {Config} from 'types/config';
import CheckSetting from './CheckSetting';
import RadioSetting from './RadioSetting';
export default function NotificationSetting({
onSave,
value,
}: {
onSave: (key: 'notifications', value: Config['notifications']) => void;
value: Config['notifications'];
}) {
if (window.process.platform === 'darwin') {
return (
<RadioSetting
id='notifications.bounceIconType'
onSave={(k, v) => onSave('notifications', {
...value,
bounceIcon: Boolean(v),
bounceIconType: v,
})}
value={value.bounceIconType}
label={(
<FormattedMessage
id='renderer.components.settingsPage.bounceIconType'
defaultMessage='Bounce the Dock icon...'
/>
)}
options={[
{
value: 'informational',
label: (
<FormattedMessage
id='renderer.components.settingsPage.bounceIcon.once'
defaultMessage='Once'
/>
),
},
{
value: 'critical',
label: (
<FormattedMessage
id='renderer.components.settingsPage.bounceIcon.untilOpenApp'
defaultMessage='Until I open the app'
/>
),
},
{
value: '',
label: (
<FormattedMessage
id='renderer.components.settingsPage.bounceIcon.never'
defaultMessage='Never'
/>
),
},
]}
/>
);
}
return (
<CheckSetting
id='flashWindow'
onSave={(k, v) => onSave('notifications', {...value, [k]: v ? 2 : 0})}
value={value.flashWindow === 2}
label={(
<FormattedMessage
id='renderer.components.settingsPage.flashWindow'
defaultMessage='Flash taskbar icon when a new message is received'
/>
)}
subLabel={(
<>
<FormattedMessage
id='renderer.components.settingsPage.flashWindow.description'
defaultMessage='If enabled, the taskbar icon will flash for a few seconds when a new message is received.'
/>
{window.process.platform === 'linux' &&
<>
<br/>
<em>
<strong>
<FormattedMessage
id='renderer.components.settingsPage.flashWindow.description.note'
defaultMessage='NOTE: '
/>
</strong>
<FormattedMessage
id='renderer.components.settingsPage.flashWindow.description.linuxFunctionality'
defaultMessage='This functionality may not work with all Linux window managers.'
/>
</em>
</>}
</>
)}
/>
);
}

View File

@@ -0,0 +1,81 @@
.RadioSetting {
.RadioSetting__heading {
font-size: 12px;
line-height: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.RadioSetting__content {
display: flex;
flex-direction: column;
/* Create a custom radio button */
.RadioSetting__radio {
display: inline-block;
position: relative;
padding-left: 28px;
cursor: pointer;
font-size: 16px;
user-select: none;
border: none;
background: none;
color: var(--center-channel-color);
text-align: left;
+ .RadioSetting__radio {
margin-top: 12px;
}
input[type="radio"] {
display: none;
cursor: pointer;
+ .RadioSetting__label::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.24);
border-radius: 50%;
transition: border-color ease-in 0.175s;
}
+ .RadioSetting__label::after {
content: "";
position: absolute;
left: 9px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-radius: 50%;
background-color: var(--button-bg);
transition: width ease-in-out 0.175s, height ease-in-out 0.175s, left ease-in-out 0.175s;
}
&:checked {
+ .RadioSetting__label::before {
border-color: var(--button-bg);
}
+ .RadioSetting__label::after {
width: 8px;
height: 8px;
left: 5px;
}
}
}
.RadioSetting__label {
display: block;
font-size: 14px;
line-height: 20px;
cursor: inherit;
}
}
}
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import './RadioSetting.scss';
export default function RadioSetting<T extends string>({
id,
onSave,
label,
options,
...props
}: {
id: string;
onSave: (key: string, value: T) => void;
label: React.ReactNode;
value: T;
options: Array<{value: T; label: React.ReactNode}>;
}) {
const [value, setValue] = useState(props.value);
const save = (value: T) => {
onSave(id, value);
setValue(value);
};
return (
<div className='RadioSetting'>
<div className='RadioSetting__heading'>{label}</div>
<div
className='RadioSetting__content'
role='radiogroup'
>
{options.map((option, index) => (
<button
id={`RadioSetting_${id}_${option.value}`}
className='RadioSetting__radio'
key={`${index}`}
onClick={() => save(option.value)}
role='radio'
aria-checked={value === option.value}
>
<input
type='radio'
value={option.value}
name={id}
checked={value === option.value}
readOnly={true}
/>
<label
htmlFor={`RadioSetting_${id}_${option.value}`}
className='RadioSetting__label'
>
{option.label}
</label>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
.SelectSetting {
&.SelectSetting-bottomBorder {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
padding-bottom: 24px;
}
.SelectSetting__select {
margin-top: 16px;
width: 50%;
.SelectSetting__select__single-value {
color: rgba(var(--center-channel-color-rgb), 0.64);
}
.SelectSetting__select__control {
background: var(--center-channel-bg);
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: var(--radius-s);
box-sizing: border-box;
&:hover {
border-color: rgba(var(--center-channel-color-rgb), 0.48);
}
&:focus-within {
border-color: var(--button-bg);
border-width: 1px;
}
}
.SelectSetting__select__menu {
background: var(--center-channel-bg);
}
.SelectSetting__select__menu-portal {
z-index: 10;
}
.SelectSetting__select__option {
background: var(--center-channel-bg);
color: var(--center-channel-color);
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
}
}
.SelectSetting__select__indicator {
color: rgba(var(--center-channel-color-rgb), 0.64);
}
.SelectSetting__select__indicator-separator {
display: none;
}
.SelectSetting__select__multi-value {
border-radius: var(--radius-l);
border: none;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
.SelectSetting__select__multi-value__label {
font-size: 12px;
font-weight: 600;
line-height: 15px;
color: var(--center-channel-color);
padding: 4.5px;
padding-left: 10px;
}
.SelectSetting__select__multi-value__remove {
padding: 0;
margin: 4.5px 10px 4.5px 1.5px;
border-radius: 50%;
background: rgba(var(--center-channel-color-rgb), 0.32);
&:hover {
color: inherit;
};
}
}
}
.SelectSetting__heading {
margin: 0;
}
.SelectSetting__label {
margin-top: 8px;
font-size: 12px;
line-height: 16px;
color: rgba(var(--center-channel-color-rgb), 0.64);
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useState} from 'react';
import ReactSelect from 'react-select';
import type {ActionMeta, MultiValue, PropsValue, SingleValue} from 'react-select';
import './SelectSetting.scss';
type Option = {value: string; label: string};
type IsMulti = {
isMulti: true;
value: string[];
onSave: (key: string, value: string[]) => void;
} | {
isMulti: false;
value: string;
onSave: (key: string, value: string) => void;
}
type Props = IsMulti & {
id: string;
label: React.ReactNode;
options: Option[];
subLabel?: React.ReactNode;
placeholder?: React.ReactNode;
bottomBorder?: boolean;
};
function valueToOption(value: string | string[], options: Option[]): PropsValue<Option> {
if (Array.isArray(value)) {
return value.map((v) => options.find((o) => o.value === v)!);
}
return options.find((o) => o.value === value)!;
}
export default function SelectSetting({
id,
onSave,
label,
options,
isMulti,
subLabel,
bottomBorder,
value: propValue,
placeholder,
}: Props) {
const [value, setValue] = useState<PropsValue<Option>>(valueToOption(propValue, options));
const save = (newValue: PropsValue<Option>, actionMeta: ActionMeta<Option>) => {
if (isMulti) {
let values = [...(value as MultiValue<Option>).map((v) => v.value)];
switch (actionMeta.action) {
case 'select-option':
values = [...(newValue as MultiValue<Option>).map((v) => v.value)];
break;
case 'remove-value':
values = values.filter((v) => v !== actionMeta.removedValue.value);
break;
case 'clear':
values = [];
break;
}
onSave(id, values);
} else {
const singleValue = newValue as SingleValue<Option>;
if (!singleValue) {
return;
}
onSave(id, singleValue.value);
}
setValue(newValue);
};
return (
<div className={classNames('SelectSetting', {'SelectSetting-bottomBorder': bottomBorder})}>
<h3 className='SelectSetting__heading'>
{label}
</h3>
{subLabel && <div className='SelectSetting__label'>
{subLabel}
</div>}
<ReactSelect
inputId={`selectSetting_${id}`}
className='SelectSetting__select'
classNamePrefix='SelectSetting__select'
options={options}
onChange={save}
isMulti={isMulti}
value={value}
menuPosition='fixed'
placeholder={placeholder}
/>
</div>
);
}

View File

@@ -0,0 +1,81 @@
.ServerSetting {
width: 100%;
display: flex;
flex: 1;
flex-direction: column;
.ServerSetting__heading {
display: flex;
padding-bottom: 20px;
align-items: center;
border-bottom: 1px solid var(--center-channel-color-8, rgba(63, 67, 80, 0.08));
h3 {
margin-right: auto;
font-size: 16px;
font-weight: 600;
line-height: 24px;
margin-top: 0;
margin-bottom: 0;
}
}
.ServerSetting__server {
display: flex;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--center-channel-color-8, rgba(63, 67, 80, 0.08));
i.icon-server-variant {
font-size: 18px;
&::before {
margin: 0;
}
}
.ServerSetting__serverName {
margin-left: 12px;
font-size: 14px;
line-height: 20px;
}
.ServerSetting__serverUrl {
margin-left: 6px;
font-size: 12px;
line-height: 18px;
color: rgba(var(--center-channel-color-rgb), 0.56);
margin-right: auto;
}
.btn.btn-tertiary.btn-danger:not(:hover) {
background-color: transparent;
}
}
.ServerSetting__noServers {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
span {
color: rgba(var(--center-channel-color-rgb), 0.64)
}
.ServerSetting__noServersTitle {
font-size: 14px;
line-height: 20px;
font-weight: 600;
margin-top: 24px;
}
.ServerSetting__noServersDescription {
font-size: 12px;
line-height: 16px;
max-width: 283px;
}
}
}

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import ServerSmallImage from 'renderer/components/Images/server-small';
import NewServerModal from 'renderer/components/NewServerModal';
import RemoveServerModal from 'renderer/components/RemoveServerModal';
import type {Server, UniqueServer} from 'types/config';
import type {Permissions, UniqueServerWithPermissions} from 'types/permissions';
import './ServerSetting.scss';
enum Modal {
ADD = 1,
EDIT,
REMOVE,
}
export default function ServerSetting() {
const [servers, setServers] = useState<UniqueServerWithPermissions[]>([]);
const [currentServer, setCurrentServer] = useState<UniqueServerWithPermissions>();
const [modal, setModal] = useState<Modal>();
const reloadServers = useCallback(() => {
window.desktop.getUniqueServersWithPermissions().then(setServers);
}, []);
useEffect(() => {
const off = window.desktop.onReloadConfiguration(reloadServers);
reloadServers();
return () => off();
}, []);
const closeModal = () => {
setCurrentServer(undefined);
setModal(undefined);
};
const addServer = (server: Server) => {
window.desktop.addServer(server);
closeModal();
};
const editServer = (server: UniqueServer, permissions?: Permissions) => {
window.desktop.editServer(server, permissions);
closeModal();
};
const removeServer = () => {
if (currentServer?.server.id) {
window.desktop.removeServer(currentServer.server.id);
}
closeModal();
};
const showAddServerModal = () => {
setModal(Modal.ADD);
};
const showEditServerModal = (server: UniqueServerWithPermissions) => () => {
setCurrentServer(server);
setModal(Modal.EDIT);
};
const showRemoveServerModal = (server: UniqueServerWithPermissions) => () => {
setCurrentServer(server);
setModal(Modal.REMOVE);
};
let openModal;
switch (modal) {
case Modal.ADD:
openModal = (
<NewServerModal
onClose={closeModal}
onSave={addServer}
show={true}
/>
);
break;
case Modal.EDIT:
openModal = (
<NewServerModal
onClose={closeModal}
onSave={editServer}
editMode={true}
show={true}
server={currentServer?.server}
permissions={currentServer?.permissions}
/>
);
break;
case Modal.REMOVE:
openModal = (
<RemoveServerModal
show={true}
onHide={closeModal}
onCancel={closeModal}
onAccept={removeServer}
/>
);
break;
}
return (
<>
<div className='ServerSetting'>
<div className='ServerSetting__heading'>
<h3>
<FormattedMessage
id='renderer.components.settingsPage.serverSetting.title'
defaultMessage='Servers'
/>
</h3>
<button
onClick={showAddServerModal}
className='ServerSetting__addServer btn btn-sm btn-tertiary'
>
<i className='icon icon-plus'/>
<FormattedMessage
id='renderer.components.settingsPage.serverSetting.addAServer'
defaultMessage='Add a server'
/>
</button>
</div>
{servers.length === 0 && (
<div className='ServerSetting__noServers'>
<ServerSmallImage/>
<div className='ServerSetting__noServersTitle'>
<FormattedMessage
id='renderer.components.settingsPage.serverSetting.noServers'
defaultMessage='No servers added'
/>
</div>
<div className='ServerSetting__noServersDescription'>
<FormattedMessage
id='renderer.components.settingsPage.serverSetting.noServers.description'
defaultMessage="Add a server to connect to your team's communication hub"
/>
</div>
</div>
)}
<div className='ServerSetting__serverList'>
{(servers.map((server, index) => (
<div
key={`${index}`}
className='ServerSetting__server'
>
<i className='icon icon-server-variant'/>
<div className='ServerSetting__serverName'>
{server.server.name}
</div>
<div className='ServerSetting__serverUrl'>
{server.server.url}
</div>
<button
onClick={showEditServerModal(server)}
className='ServerSetting__editServer btn btn-icon btn-sm'
>
<i className='icon icon-pencil-outline'/>
</button>
<button
onClick={showRemoveServerModal(server)}
className='ServerSetting__removeServer btn btn-icon btn-sm btn-tertiary btn-transparent btn-danger'
>
<i className='icon icon-trash-can-outline'/>
</button>
</div>
)))}
</div>
{openModal}
</div>
</>
);
}

View File

@@ -0,0 +1,57 @@
.SpellCheckerSetting {
> .SelectSetting {
margin-top: 24px;
.SelectSetting__heading {
font-size: 14px;
line-height: 20px;
}
.SelectSetting__select {
width: 100%;
}
};
h3 {
margin-top: 0;
font-size: 16px;
line-height: 24px;
}
.SpellCheckerSetting__alternative {
.SpellCheckerSetting__alternative__content {
display: flex;
margin-top: 16px;
.Input_container {
margin-top: 0;
input {
height: 34px;
font-size: 14px;
line-height: 20px;
}
}
button {
margin-left: 8px;
}
}
.SpellCheckerSetting__alternative__heading {
margin-bottom: 0;
}
.SpellCheckerSetting__alternative__label {
margin-top: 8px;
font-size: 12px;
line-height: 16px;
color: rgba(var(--center-channel-color-rgb), 0.64);
}
}
}
div + .SpellCheckerSetting {
padding-top: 24px;
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useEffect, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import Input, {SIZE} from 'renderer/components/Input';
import CheckSetting from './CheckSetting';
import SelectSetting from './SelectSetting';
import './SpellCheckerSetting.scss';
type Option = {value: string; label: string};
export default function SpellCheckerSetting({
id,
onSave,
label,
options,
subLabel,
heading,
value: propValue,
}: {
id: string;
onSave: (key: string, value: string | boolean | string[]) => void;
label: React.ReactNode;
options: Option[];
subLabel?: React.ReactNode;
heading?: React.ReactNode;
value: boolean;
}) {
const [spellCheckerLocales, setSpellCheckerLocales] = useState<string[]>();
const [spellCheckerURL, setSpellCheckerURL] = useState<string>();
const [editingURL, setEditingURL] = useState(false);
useEffect(() => {
// Unfortunately we need to sidestep the props for this one as it is a very special case
window.desktop.getLocalConfiguration().then((config) => {
setSpellCheckerLocales(config.spellCheckerLocales);
setSpellCheckerURL(config.spellCheckerURL);
});
}, []);
const saveSpellCheckerLocales = (key: string, newValue: string[]) => {
onSave('spellCheckerLocales', newValue);
setSpellCheckerLocales(newValue);
};
const editURL = () => {
if (editingURL) {
onSave('spellCheckerURL', spellCheckerURL ?? '');
}
setEditingURL(!editingURL);
};
if (!spellCheckerLocales) {
return null;
}
return (
<div className='SpellCheckerSetting'>
<CheckSetting
id={id}
onSave={onSave}
label={label}
subLabel={subLabel}
value={propValue}
heading={heading}
/>
{propValue &&
<SelectSetting
id='spellCheckerLocales'
onSave={saveSpellCheckerLocales}
label={(
<FormattedMessage
id='renderer.components.settingsPage.spellCheckerSetting.language'
defaultMessage='Spell Checker Languages'
/>
)}
options={options}
value={spellCheckerLocales}
isMulti={true}
placeholder={
<FormattedMessage
id='renderer.components.settingsPage.checkSpelling.preferredLanguages'
defaultMessage='Select preferred language(s)'
/>
}
/>
}
{propValue &&
<div className='SpellCheckerSetting__alternative'>
<h4 className='SpellCheckerSetting__alternative__heading'>
<FormattedMessage
id='renderer.components.settingsPage.checkSpelling.editSpellcheckUrl'
defaultMessage='Use an alternative dictionary URL'
/>
</h4>
<div className='SpellCheckerSetting__alternative__label'>
<FormattedMessage
id='renderer.components.settingsPage.checkSpelling.specifyURL'
defaultMessage='Specify the url where dictionary definitions can be retrieved'
/>
</div>
<div className='SpellCheckerSetting__alternative__content'>
<Input
disabled={!editingURL}
value={spellCheckerURL}
inputSize={SIZE.MEDIUM}
onChange={(e) => setSpellCheckerURL(e.target.value)}
/>
<button
className={classNames('DownloadSetting__changeButton btn', {
'btn-primary': editingURL,
'btn-tertiary': !editingURL,
})}
id='saveDownloadLocation'
onClick={editURL}
>
{editingURL &&
<FormattedMessage
id='label.save'
defaultMessage='Save'
/>
}
{!editingURL &&
<FormattedMessage
id='label.change'
defaultMessage='Change'
/>
}
</button>
</div>
</div>
}
</div>
);
}

View File

@@ -0,0 +1,7 @@
.UpdatesSetting__button {
margin-top: 8px;
}
.UpdatesSetting__subLabel > span {
display: block;
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import CheckSetting from './CheckSetting';
import './UpdatesSetting.scss';
export default function UpdatesSetting({
id,
onSave,
value,
}: {
id: string;
onSave: (key: string, value: boolean) => void;
value: boolean;
}) {
return (
<>
<CheckSetting
id={id}
onSave={onSave}
value={value}
label={
<FormattedMessage
id='renderer.components.settingsPage.updates.automatic'
defaultMessage='Automatically check for updates'
/>
}
subLabel={
<div className='UpdatesSetting__subLabel'>
<FormattedMessage
id='renderer.components.settingsPage.updates.automatic.description'
defaultMessage='If enabled, updates to the Desktop App will download automatically and you will be notified when ready to install.'
/>
<button
className='UpdatesSetting__button btn btn-primary'
id='checkForUpdatesNow'
onClick={window.desktop.checkForUpdates}
>
<FormattedMessage
id='renderer.components.settingsPage.updates.checkNow'
defaultMessage='Check for Updates Now'
/>
</button>
</div>
}
/>
</>
);
}

View File

@@ -0,0 +1,449 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {IntlShape} from 'react-intl';
import {localeTranslations} from 'common/utils/constants';
import type {SettingsDefinition} from 'types/settings';
import CheckSetting from './components/CheckSetting';
import DownloadSetting from './components/DownloadSetting';
import NotificationSetting from './components/NotificationSetting';
import RadioSetting from './components/RadioSetting';
import SelectSetting from './components/SelectSetting';
import ServerSetting from './components/ServerSetting';
import SpellCheckerSetting from './components/SpellCheckerSetting';
import UpdatesSetting from './components/UpdatesSetting';
const getLanguages = async (func: () => Promise<string[]>) => {
return (await func()).filter((language) => localeTranslations[language]).
map((language) => ({label: localeTranslations[language], value: language})).
sort((a, b) => a.label.localeCompare(b.label));
};
const definition: (intl: IntlShape) => Promise<SettingsDefinition> = async (intl: IntlShape) => {
return {
general: {
title: (
<FormattedMessage
id='renderer.components.settingsPage.general'
defaultMessage='General'
/>
),
icon: 'settings-outline',
settings: [
{
id: 'autoCheckForUpdates',
component: UpdatesSetting,
condition: (await window.desktop.getLocalConfiguration()).canUpgrade,
},
{
id: 'downloadLocation',
component: DownloadSetting,
},
{
id: 'autostart',
component: CheckSetting,
condition: window.process.platform === 'win32' || window.process.platform === 'linux',
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.startAppOnLogin'
defaultMessage='Start app on login'
/>
),
subLabel: (
<FormattedMessage
id='renderer.components.settingsPage.startAppOnLogin.description'
defaultMessage='If enabled, the app starts automatically when you log in to your machine.'
/>
),
},
},
{
id: 'hideOnStart',
component: CheckSetting,
condition: window.process.platform === 'win32' || window.process.platform === 'linux',
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.launchAppMinimized'
defaultMessage='Launch app minimized'
/>
),
subLabel: (
<FormattedMessage
id='renderer.components.settingsPage.launchAppMinimized.description'
defaultMessage='If enabled, the app will start in system tray, and will not show the window on launch.'
/>
),
},
},
{
id: 'showTrayIcon',
component: CheckSetting,
condition: window.process.platform === 'darwin' || window.process.platform === 'linux',
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.trayIcon.show'
defaultMessage='Show icon in the notification area'
/>
),
subLabel: (
<FormattedMessage
id='renderer.components.settingsPage.afterRestart'
defaultMessage='Setting takes effect after restarting the app.'
/>
),
},
},
{
id: 'trayIconTheme',
component: RadioSetting,
condition: (window.process.platform === 'linux' || window.process.platform === 'win32') && (await window.desktop.getLocalConfiguration()).showTrayIcon,
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.trayIcon.color'
defaultMessage='Icon color'
/>
),
options: [
{
value: 'use_system',
label: (
<FormattedMessage
id='renderer.components.settingsPage.trayIcon.theme.systemDefault'
defaultMessage='Use system default'
/>
),
},
{
value: 'light',
label: (
<FormattedMessage
id='renderer.components.settingsPage.trayIcon.theme.light'
defaultMessage='Light'
/>
),
},
{
value: 'dark',
label: (
<FormattedMessage
id='renderer.components.settingsPage.trayIcon.theme.dark'
defaultMessage='Dark'
/>
),
},
],
},
},
{
id: 'minimizeToTray',
component: CheckSetting,
condition: window.process.platform === 'linux' || window.process.platform === 'win32',
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.minimizeToTray'
defaultMessage='Leave app running in notification area when application window is closed'
/>
),
subLabel: (
<>
<FormattedMessage
id='renderer.components.settingsPage.minimizeToTray.description'
defaultMessage='If enabled, the app stays running in the notification area after app window is closed.'
/>
<br/>
<FormattedMessage
id='renderer.components.settingsPage.afterRestart'
defaultMessage='Setting takes effect after restarting the app.'
/>
</>
),
},
},
{
id: 'startInFullscreen',
component: CheckSetting,
condition: window.process.platform !== 'linux',
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.fullscreen'
defaultMessage='Open app in full screen'
/>
),
subLabel: (
<FormattedMessage
id='renderer.components.settingsPage.fullscreen.description'
defaultMessage='If enabled, the {appName} application will always open in full screen'
values={{appName: (await window.desktop.getVersion()).name}}
/>
),
},
},
],
},
notifications: {
title: (
<FormattedMessage
id='renderer.components.settingsPage.notifications'
defaultMessage='Notifications'
/>
),
icon: 'bell-outline',
settings: [
{
id: 'showUnreadBadge',
component: CheckSetting,
condition: window.process.platform === 'darwin' || window.process.platform === 'win32',
props: {
heading: (
<FormattedMessage
id='renderer.components.settingsPage.showUnreadBadge.heading'
defaultMessage='Unread Badge'
/>
),
label: (
<FormattedMessage
id='renderer.components.settingsPage.showUnreadBadge'
defaultMessage='Show red badge on {taskbar} icon to indicate unread messages'
values={{taskbar: window.process.platform === 'win32' ? 'taskbar' : 'Dock'}}
/>
),
subLabel: (
<FormattedMessage
id='renderer.components.settingsPage.showUnreadBadge.description'
defaultMessage='Regardless of this setting, mentions are always indicated with a red badge and item count on the {taskbar} icon.'
values={{taskbar: window.process.platform === 'win32' ? 'taskbar' : 'Dock'}}
/>
),
},
},
{
id: 'notifications',
component: NotificationSetting,
},
],
},
language: {
title: (
<FormattedMessage
id='renderer.components.settingsPage.language'
defaultMessage='Language'
/>
),
icon: 'globe',
settings: [
{
id: 'appLanguage',
component: SelectSetting,
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.appLanguage'
defaultMessage='App Language'
/>
),
subLabel: (
<>
<FormattedMessage
id='renderer.components.settingsPage.appLanguage.description'
defaultMessage='The language that the Desktop App will use for menu items and popups. Still in beta, some languages will be missing translation strings.'
/>
&nbsp;
<FormattedMessage
id='renderer.components.settingsPage.afterRestart'
defaultMessage='Setting takes effect after restarting the app.'
/>
</>
),
placeholder: (
<FormattedMessage
id='renderer.components.settingsPage.appLanguage.placeholder'
defaultMessage='Use system default'
/>
),
options: await getLanguages(window.desktop.getAvailableLanguages),
},
},
{
id: 'useSpellChecker',
component: SpellCheckerSetting,
props: {
heading: (
<h3>
<FormattedMessage
id='renderer.components.settingsPage.spellChecker'
defaultMessage='Spell Checker'
/>
</h3>
),
label: (
<FormattedMessage
id='renderer.components.settingsPage.checkSpelling'
defaultMessage='Check spelling'
/>
),
subLabel: (
<>
<FormattedMessage
id='renderer.components.settingsPage.checkSpelling.description'
defaultMessage='Highlight misspelled words in your messages based on your system language or language preference.'
/>
&nbsp;
<FormattedMessage
id='renderer.components.settingsPage.afterRestart'
defaultMessage='Setting takes effect after restarting the app.'
/>
</>
),
options: await getLanguages(window.desktop.getAvailableSpellCheckerLanguages),
},
},
],
},
servers: {
title: (
<FormattedMessage
id='renderer.components.settingsPage.servers'
defaultMessage='Servers'
/>
),
icon: 'server-variant',
settings: [
{
id: 'teams',
component: ServerSetting,
},
],
},
advanced: {
title: (
<FormattedMessage
id='renderer.components.settingsPage.advanced'
defaultMessage='Advanced'
/>
),
icon: 'tune',
settings: [
{
id: 'logLevel',
component: SelectSetting,
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.loggingLevel'
defaultMessage='Logging level'
/>
),
subLabel: (
<FormattedMessage
id='renderer.components.settingsPage.loggingLevel.description'
defaultMessage='Logging is helpful for developers and support to isolate issues you may be encountering with the desktop app.'
/>
),
bottomBorder: true,
options: [
{
value: 'error',
label: intl.formatMessage({
id: 'renderer.components.settingsPage.loggingLevel.level.error',
defaultMessage: 'Errors (error)',
}),
},
{
value: 'warn',
label: intl.formatMessage({
id: 'renderer.components.settingsPage.loggingLevel.level.warn',
defaultMessage: 'Errors and Warnings (warn)',
}),
},
{
value: 'info',
label: intl.formatMessage({
id: 'renderer.components.settingsPage.loggingLevel.level.info',
defaultMessage: 'Info (info)',
}),
},
{
value: 'verbose',
label: intl.formatMessage({
id: 'renderer.components.settingsPage.loggingLevel.level.verbose',
defaultMessage: 'Verbose (verbose)',
}),
},
{
value: 'debug',
label: intl.formatMessage({
id: 'renderer.components.settingsPage.loggingLevel.level.debug',
defaultMessage: 'Debug (debug)',
}),
},
{
value: 'silly',
label: intl.formatMessage({
id: 'renderer.components.settingsPage.loggingLevel.level.silly',
defaultMessage: 'Finest (silly)',
}),
},
],
},
},
{
id: 'enableMetrics',
component: CheckSetting,
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.enableMetrics'
defaultMessage='Send anonymous usage data to your configured servers'
/>
),
subLabel: (
<FormattedMessage
id='renderer.components.settingsPage.enableMetrics.description'
defaultMessage='Sends usage data about the application and its performance to your configured servers that accept it.'
/>
),
},
},
{
id: 'enableHardwareAcceleration',
component: CheckSetting,
props: {
label: (
<FormattedMessage
id='renderer.components.settingsPage.enableHardwareAcceleration'
defaultMessage='Use GPU hardware acceleration'
/>
),
subLabel: (
<>
<FormattedMessage
id='renderer.components.settingsPage.enableHardwareAcceleration.description'
defaultMessage='If enabled, {appName} UI is rendered more efficiently but can lead to decreased stability for some systems.'
values={{appName: (await window.desktop.getVersion()).name}}
/>
&nbsp;
<FormattedMessage
id='renderer.components.settingsPage.afterRestart'
defaultMessage='Setting takes effect after restarting the app.'
/>
</>
),
},
},
],
},
};
};
export default definition;

View File

@@ -0,0 +1,190 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import createCache from '@emotion/cache';
import type {EmotionCache} from '@emotion/react';
import {CacheProvider} from '@emotion/react';
import classNames from 'classnames';
import React, {useEffect, useRef, useState, useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {Modal} from 'renderer/components/Modal';
import type {Config, LocalConfiguration} from 'types/config';
import type {SaveQueueItem, SettingsDefinition} from 'types/settings';
import generateDefinition from './definition';
import './SettingsModal.scss';
enum SavingState {
SAVING = 1,
SAVED,
DONE
}
export default function SettingsModal({
onClose,
}: {
onClose: () => void;
}) {
const intl = useIntl();
const saveQueue = useRef<SaveQueueItem[]>([]);
const saveDebounce = useRef<boolean>(false);
const resetDebounce = useRef<boolean>(false);
const [savingState, setSavingState] = useState<SavingState>(SavingState.DONE);
const [selectedCategory, setSelectedCategory] = useState<string>();
const [config, setConfig] = useState<LocalConfiguration>();
const [definition, setDefinition] = useState<SettingsDefinition>();
const [cache, setCache] = useState<EmotionCache>();
const getConfig = useCallback(() => {
window.desktop.getLocalConfiguration().then((result) => {
setConfig(result);
});
}, []);
const getDefinition = useCallback(() => {
return generateDefinition(intl).then((result) => {
setDefinition(result);
return result;
});
}, [intl, selectedCategory]);
const setSavingStateToDone = useCallback(() => {
resetDebounce.current = false;
if (savingState !== SavingState.SAVING) {
setSavingState(SavingState.DONE);
}
}, [savingState]);
const resetSaveState = useCallback(() => {
if (resetDebounce.current) {
return;
}
resetDebounce.current = true;
setTimeout(setSavingStateToDone, 2000);
}, [setSavingStateToDone]);
const updateConfiguration = useCallback(() => {
if (saveQueue.current.length === 0) {
setSavingState(SavingState.SAVED);
resetSaveState();
}
getConfig();
getDefinition();
}, [getConfig, resetSaveState]);
const sendSave = useCallback(() => {
saveDebounce.current = false;
window.desktop.updateConfiguration(saveQueue.current.splice(0, saveQueue.current.length));
}, []);
const processSaveQueue = useCallback(() => {
if (saveDebounce.current) {
return;
}
saveDebounce.current = true;
setTimeout(sendSave, 500);
}, [sendSave]);
const save = useCallback((key: keyof Config, data: Config[keyof Config]) => {
saveQueue.current.push({
key,
data,
});
setSavingState(SavingState.SAVING);
processSaveQueue();
}, [processSaveQueue]);
useEffect(() => {
window.desktop.getNonce().then((nonce) => {
setCache(createCache({
key: 'react-select-cache',
nonce,
}));
});
window.desktop.onReloadConfiguration(updateConfiguration);
getDefinition().then((definition) => {
setSelectedCategory(Object.keys(definition)[0]);
});
getConfig();
}, []);
let savingText;
if (savingState === SavingState.SAVING) {
savingText = (
<div className='SettingsModal__saving'>
<i className='icon-spinner'/>
<FormattedMessage
id='renderer.components.settingsPage.saving'
defaultMessage='Saving...'
/>
</div>
);
} else if (savingState === SavingState.SAVED) {
savingText = (
<div className='SettingsModal__saving'>
<i className='icon-check'/>
<FormattedMessage
id='renderer.components.settingsPage.changesSaved'
defaultMessage='Changes saved'
/>
</div>
);
}
if (!cache) {
return null;
}
return (
<CacheProvider value={cache}>
<Modal
id='settingsModal'
className='SettingsModal'
show={Boolean(config && definition && selectedCategory)}
onExited={onClose}
modalHeaderText={
<FormattedMessage
id='renderer.components.settingsPage.header'
defaultMessage='Desktop App Settings'
/>
}
headerContent={savingText}
bodyDivider={true}
bodyPadding={false}
>
<div className='SettingsModal__sidebar'>
{definition && Object.entries(definition).map(([id, category]) => (
<button
id={`settingCategoryButton-${id}`}
key={id}
className={classNames('SettingsModal__category', {selected: id === selectedCategory})}
onClick={() => setSelectedCategory(id)}
>
<i className={`icon icon-${category.icon}`}/>
{category.title}
</button>
))}
</div>
<div className='SettingsModal__content'>
{(config && definition && selectedCategory) && definition[selectedCategory].settings.map((setting) => (setting.condition ?? true) && (
<setting.component
key={setting.id}
id={setting.id}
onSave={save}
value={config[setting.id]}
{...setting.props}
/>
))}
</div>
</Modal>
</CacheProvider>
);
}

File diff suppressed because it is too large Load Diff