Connection Status: Indicate when syncing, different positions

This commit is contained in:
Alexander Zinchuk 2022-01-25 03:24:34 +01:00
parent b2d06ff289
commit eb6e5f5e88
29 changed files with 419 additions and 192 deletions

View File

@ -55,7 +55,9 @@ declare module 'pako/dist/pako_inflate' {
function inflate(...args: any[]): string; function inflate(...args: any[]): string;
} }
type WindowWithPerf = typeof window & { perf: AnyLiteral }; type WindowWithPerf =
typeof window
& { perf: AnyLiteral };
interface TEncodedImage { interface TEncodedImage {
result: Uint8ClampedArray; result: Uint8ClampedArray;
@ -70,10 +72,12 @@ interface IWebpWorker extends Worker {
interface Window { interface Window {
ClipboardItem?: any; ClipboardItem?: any;
requestIdleCallback: (cb: AnyToVoidFunction, options:{ timeout?: number }) => void; requestIdleCallback: (cb: AnyToVoidFunction, options: { timeout?: number }) => void;
} }
interface Clipboard { write?: any } interface Clipboard {
write?: any;
}
interface Document { interface Document {
mozFullScreenElement: any; mozFullScreenElement: any;

View File

@ -0,0 +1,32 @@
.DotAnimation {
display: inline-flex;
align-items: baseline;
.ellipsis {
display: flex;
width: 1rem;
overflow: hidden;
&::after {
content: '...';
animation: dot-animation 1s steps(4, start) infinite;
html[lang=ar] &,
html[lang=fa] & {
animation-name: dot-animation-rtl;
}
}
}
}
@keyframes dot-animation {
from {
transform: translateX(-1rem);
}
}
@keyframes dot-animation-rtl {
from {
transform: translateX(1rem);
}
}

View File

@ -0,0 +1,23 @@
import React, { FC } from '../../lib/teact/teact';
import useLang from '../../hooks/useLang';
import buildClassName from '../../util/buildClassName';
import './DotAnimation.scss';
type OwnProps = {
content: string;
className?: string;
};
const DotAnimation: FC<OwnProps> = ({ content, className }) => {
const lang = useLang();
return (
<span className={buildClassName('DotAnimation', className)} dir={lang.isRtl ? 'rtl' : 'auto'}>
{content}
<span className="ellipsis" />
</span>
);
};
export default DotAnimation;

View File

@ -20,11 +20,14 @@ import useLang, { LangFn } from '../../hooks/useLang';
import Avatar from './Avatar'; import Avatar from './Avatar';
import VerifiedIcon from './VerifiedIcon'; import VerifiedIcon from './VerifiedIcon';
import TypingStatus from './TypingStatus'; import TypingStatus from './TypingStatus';
import DotAnimation from './DotAnimation';
type OwnProps = { type OwnProps = {
chatId: string; chatId: string;
typingStatus?: ApiTypingStatus; typingStatus?: ApiTypingStatus;
avatarSize?: 'small' | 'medium' | 'large' | 'jumbo'; avatarSize?: 'small' | 'medium' | 'large' | 'jumbo';
status?: string;
withDots?: boolean;
withMediaViewer?: boolean; withMediaViewer?: boolean;
withUsername?: boolean; withUsername?: boolean;
withFullInfo?: boolean; withFullInfo?: boolean;
@ -44,6 +47,8 @@ type StateProps =
const GroupChatInfo: FC<OwnProps & StateProps> = ({ const GroupChatInfo: FC<OwnProps & StateProps> = ({
typingStatus, typingStatus,
avatarSize = 'medium', avatarSize = 'medium',
status,
withDots,
withMediaViewer, withMediaViewer,
withUsername, withUsername,
withFullInfo, withFullInfo,
@ -86,9 +91,17 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
} }
function renderStatusOrTyping() { function renderStatusOrTyping() {
if (status) {
return withDots ? (
<DotAnimation className="status" content={status} />
) : (
<span className="status" dir="auto">{status}</span>
);
}
if (withUpdatingStatus && !areMessagesLoaded && !isRestricted) { if (withUpdatingStatus && !areMessagesLoaded && !isRestricted) {
return ( return (
<span className="status" dir="auto">{lang('Updating')}</span> <DotAnimation className="status" content={lang('Updating')} />
); );
} }

View File

@ -16,6 +16,7 @@ import useLang from '../../hooks/useLang';
import Avatar from './Avatar'; import Avatar from './Avatar';
import VerifiedIcon from './VerifiedIcon'; import VerifiedIcon from './VerifiedIcon';
import TypingStatus from './TypingStatus'; import TypingStatus from './TypingStatus';
import DotAnimation from './DotAnimation';
type OwnProps = { type OwnProps = {
userId: string; userId: string;
@ -23,6 +24,7 @@ type OwnProps = {
avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo'; avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo';
forceShowSelf?: boolean; forceShowSelf?: boolean;
status?: string; status?: string;
withDots?: boolean;
withMediaViewer?: boolean; withMediaViewer?: boolean;
withUsername?: boolean; withUsername?: boolean;
withFullInfo?: boolean; withFullInfo?: boolean;
@ -45,6 +47,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
typingStatus, typingStatus,
avatarSize = 'medium', avatarSize = 'medium',
status, status,
withDots,
withMediaViewer, withMediaViewer,
withUsername, withUsername,
withFullInfo, withFullInfo,
@ -90,14 +93,16 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
function renderStatusOrTyping() { function renderStatusOrTyping() {
if (status) { if (status) {
return ( return withDots ? (
<DotAnimation className="status" content={status} />
) : (
<span className="status" dir="auto">{status}</span> <span className="status" dir="auto">{status}</span>
); );
} }
if (withUpdatingStatus && !areMessagesLoaded) { if (withUpdatingStatus && !areMessagesLoaded) {
return ( return (
<span className="status" dir="auto">{lang('Updating')}</span> <DotAnimation className="status" content={lang('Updating')} />
); );
} }

View File

@ -8,32 +8,4 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
} }
.ellipsis {
display: flex;
width: 1rem;
overflow: hidden;
&::after {
content: '...';
animation: typing-animation 1s steps(4, start) infinite;
html[lang=ar] &,
html[lang=fa] & {
animation-name: typing-animation-rtl;
}
}
}
}
@keyframes typing-animation {
from {
transform: translateX(-1rem);
}
}
@keyframes typing-animation-rtl {
from {
transform: translateX(1rem);
}
} }

View File

@ -8,6 +8,8 @@ import { getUserFirstOrLastName } from '../../modules/helpers';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import DotAnimation from './DotAnimation';
import './TypingStatus.scss'; import './TypingStatus.scss';
type OwnProps = { type OwnProps = {
@ -21,15 +23,17 @@ type StateProps = {
const TypingStatus: FC<OwnProps & StateProps> = ({ typingStatus, typingUser }) => { const TypingStatus: FC<OwnProps & StateProps> = ({ typingStatus, typingUser }) => {
const lang = useLang(); const lang = useLang();
const typingUserName = typingUser && !typingUser.isSelf && getUserFirstOrLastName(typingUser); const typingUserName = typingUser && !typingUser.isSelf && getUserFirstOrLastName(typingUser);
const content = lang(typingStatus.action)
// Fix for translation "{user} is typing"
.replace('{user}', '')
.replace('{emoji}', typingStatus.emoji).trim();
return ( return (
<p className="typing-status" dir={lang.isRtl ? 'rtl' : 'auto'}> <p className="typing-status" dir={lang.isRtl ? 'rtl' : 'auto'}>
{typingUserName && ( {typingUserName && (
<span className="sender-name" dir="auto">{renderText(typingUserName)}</span> <span className="sender-name" dir="auto">{renderText(typingUserName)}</span>
)} )}
{/* fix for translation "username _is_ typing" */} <DotAnimation content={content} />
{lang(typingStatus.action).replace('{user}', '').replace('{emoji}', typingStatus.emoji).trim()}
<span className="ellipsis" />
</p> </p>
); );
}; };

View File

@ -1,27 +0,0 @@
#ConnectionState {
flex: 0 0 auto;
display: flex;
align-items: center;
margin: 0 0.5rem 0.5rem;
padding: 0.75rem;
background: var(--color-yellow);
border-radius: var(--border-radius-default);
> .Spinner {
--spinner-size: 1.75rem;
}
> .state-text {
color: var(--color-text-lighter);
font-weight: 500;
line-height: 2rem;
margin-inline-start: 1.875rem;
white-space: nowrap;
}
@media (max-width: 950px) {
> .state-text {
margin-inline-start: 1.25rem;
}
}
}

View File

@ -1,24 +0,0 @@
import React, { memo, FC } from '../../lib/teact/teact';
import { GlobalState } from '../../global/types';
import useLang from '../../hooks/useLang';
import Spinner from '../ui/Spinner';
import './ConnectionState.scss';
type StateProps = Pick<GlobalState, 'connectionState'>;
const ConnectionState: FC<StateProps> = () => {
const lang = useLang();
return (
<div id="ConnectionState" dir={lang.isRtl ? 'rtl' : undefined}>
<Spinner color="black" />
<div className="state-text">{lang('WaitingForNetwork')}</div>
</div>
);
};
export default memo(ConnectionState);

View File

@ -0,0 +1,66 @@
.connection-state-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
transition: transform 300ms ease, opacity 300ms ease;
opacity: 1;
&:not(.open) {
transform: translateY(-3rem);
opacity: 0;
}
&:not(.shown) {
display: none;
}
}
#ConnectionStatusOverlay {
height: 2.9375rem;
flex: 0 0 auto;
display: flex;
align-items: center;
margin: 0.375rem 0.5rem;
padding: 0 0.75rem;
background: var(--color-yellow);
border-radius: var(--border-radius-default);
&.interactive {
cursor: pointer;
}
> .Spinner {
--spinner-size: 1.75rem;
}
> .state-text {
flex: 1;
color: var(--color-text-lighter);
font-size: 0.9375rem;
font-weight: 500;
padding-bottom: 0.0625rem;
margin-inline-start: 1.875rem;
white-space: nowrap;
}
@media (max-width: 950px) {
> .state-text {
margin-inline-start: 1.25rem;
}
}
.Transition {
width: 100%;
// https://dfmcphee.com/flex-items-and-min-width-0/
// https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size
min-width: 0;
> div {
display: flex;
align-items: center;
width: 100%;
}
}
}

View File

@ -0,0 +1,44 @@
import React, { FC, memo } from '../../lib/teact/teact';
import useLang from '../../hooks/useLang';
import { ConnectionStatus } from '../../hooks/useConnectionStatus';
import Transition from '../ui/Transition';
import Spinner from '../ui/Spinner';
import Button from '../ui/Button';
import './ConnectionStatusOverlay.scss';
type OwnProps = {
connectionStatus: ConnectionStatus;
connectionStatusText: string;
onClick?: NoneToVoidFunction;
};
const ConnectionStatusOverlay: FC<OwnProps> = ({
connectionStatus,
connectionStatusText,
onClick,
}) => {
const lang = useLang();
return (
<div id="ConnectionStatusOverlay" dir={lang.isRtl ? 'rtl' : undefined} onClick={onClick}>
<Spinner color="black" />
<div className="state-text">
<Transition activeKey={connectionStatus} name="slide-fade">
{() => connectionStatusText}
</Transition>
</div>
<Button
round
size="tiny"
color="translucent-black"
>
<span className="icon-close" />
</Button>
</div>
);
};
export default memo(ConnectionStatusOverlay);

View File

@ -6,20 +6,9 @@
overflow: hidden; overflow: hidden;
z-index: 1; z-index: 1;
.connection-state-wrapper {
position: absolute;
top: 3.75rem;
width: 100%;
}
> .Transition { > .Transition {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
transition: transform 300ms ease;
&.pull-down {
transform: translateY(3.75rem);
}
} }
.ChatFolders { .ChatFolders {

View File

@ -1,28 +1,22 @@
import React, { import React, {
FC, useState, useRef, useCallback, useEffect, FC, memo, useCallback, useEffect, useRef, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn';
import { GlobalState } from '../../../global/types';
import { LeftColumnContent, SettingsScreens } from '../../../types'; import { LeftColumnContent, SettingsScreens } from '../../../types';
import { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReducer'; import { FolderEditDispatch } from '../../../hooks/reducers/useFoldersReducer';
import { IS_TOUCH_ENV } from '../../../util/environment'; import { IS_TOUCH_ENV } from '../../../util/environment';
import { pick } from '../../../util/iteratees';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import useBrowserOnline from '../../../hooks/useBrowserOnline';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import Transition from '../../ui/Transition'; import Transition from '../../ui/Transition';
import LeftMainHeader from './LeftMainHeader'; import LeftMainHeader from './LeftMainHeader';
import ConnectionState from '../ConnectionState';
import ChatFolders from './ChatFolders'; import ChatFolders from './ChatFolders';
import LeftSearch from '../search/LeftSearch.async'; import LeftSearch from '../search/LeftSearch.async';
import ContactList from './ContactList.async'; import ContactList from './ContactList.async';
import NewChatButton from '../NewChatButton'; import NewChatButton from '../NewChatButton';
import ShowTransition from '../../ui/ShowTransition';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import './LeftMain.scss'; import './LeftMain.scss';
@ -40,15 +34,13 @@ type OwnProps = {
onReset: () => void; onReset: () => void;
}; };
type StateProps = Pick<GlobalState, 'connectionState'>;
const TRANSITION_RENDER_COUNT = Object.keys(LeftColumnContent).length / 2; const TRANSITION_RENDER_COUNT = Object.keys(LeftColumnContent).length / 2;
const BUTTON_CLOSE_DELAY_MS = 250; const BUTTON_CLOSE_DELAY_MS = 250;
const APP_OUTDATED_TIMEOUT = 3 * 24 * 60 * 60 * 1000; // 3 days const APP_OUTDATED_TIMEOUT = 3 * 24 * 60 * 60 * 1000; // 3 days
let closeTimeout: number | undefined; let closeTimeout: number | undefined;
const LeftMain: FC<OwnProps & StateProps> = ({ const LeftMain: FC<OwnProps> = ({
content, content,
searchQuery, searchQuery,
searchDate, searchDate,
@ -59,13 +51,9 @@ const LeftMain: FC<OwnProps & StateProps> = ({
onContentChange, onContentChange,
onScreenSelect, onScreenSelect,
onReset, onReset,
connectionState,
}) => { }) => {
const [isNewChatButtonShown, setIsNewChatButtonShown] = useState(IS_TOUCH_ENV); const [isNewChatButtonShown, setIsNewChatButtonShown] = useState(IS_TOUCH_ENV);
const isBrowserOnline = useBrowserOnline();
const isConnecting = !isBrowserOnline || connectionState === 'connectionStateConnecting';
const isMouseInside = useRef(false); const isMouseInside = useRef(false);
const handleSelectSettings = useCallback(() => { const handleSelectSettings = useCallback(() => {
@ -149,16 +137,12 @@ const LeftMain: FC<OwnProps & StateProps> = ({
onReset={onReset} onReset={onReset}
shouldSkipTransition={shouldSkipTransition} shouldSkipTransition={shouldSkipTransition}
/> />
<ShowTransition isOpen={isConnecting} isCustom className="connection-state-wrapper opacity-transition slow">
{() => <ConnectionState />}
</ShowTransition>
<Transition <Transition
name={shouldSkipTransition ? 'none' : 'zoom-fade'} name={shouldSkipTransition ? 'none' : 'zoom-fade'}
renderCount={TRANSITION_RENDER_COUNT} renderCount={TRANSITION_RENDER_COUNT}
activeKey={content} activeKey={content}
shouldCleanup shouldCleanup
cleanupExceptionKey={LeftColumnContent.ChatList} cleanupExceptionKey={LeftColumnContent.ChatList}
className={isConnecting ? 'pull-down' : undefined}
> >
{(isActive) => { {(isActive) => {
switch (content) { switch (content) {
@ -220,6 +204,4 @@ function useAppOutdatedCheck() {
return [shouldRender, transitionClassNames, handleUpdateClick] as const; return [shouldRender, transitionClassNames, handleUpdateClick] as const;
} }
export default withGlobal<OwnProps>( export default memo(LeftMain);
(global): StateProps => pick(global, ['connectionState']),
)(LeftMain);

View File

@ -1,10 +1,11 @@
import React, { import React, {
FC, useCallback, useMemo, memo, FC, memo, useCallback, useMemo,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; import { getDispatch, withGlobal } from '../../../lib/teact/teactn';
import { LeftColumnContent, ISettings } from '../../../types'; import { ISettings, LeftColumnContent } from '../../../types';
import { ApiChat } from '../../../api/types'; import { ApiChat } from '../../../api/types';
import { GlobalState } from '../../../global/types';
import { import {
ANIMATION_LEVEL_MAX, APP_NAME, APP_VERSION, FEEDBACK_URL, ANIMATION_LEVEL_MAX, APP_NAME, APP_VERSION, FEEDBACK_URL,
@ -15,10 +16,11 @@ import { formatDateToString } from '../../../util/dateFormat';
import switchTheme from '../../../util/switchTheme'; import switchTheme from '../../../util/switchTheme';
import { setPermanentWebVersion } from '../../../util/permanentWebVersion'; import { setPermanentWebVersion } from '../../../util/permanentWebVersion';
import { clearWebsync } from '../../../util/websync'; import { clearWebsync } from '../../../util/websync';
import { selectTheme } from '../../../modules/selectors'; import { selectCurrentMessageList, selectTheme } from '../../../modules/selectors';
import { isChatArchived } from '../../../modules/helpers'; import { isChatArchived } from '../../../modules/helpers';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import { disableHistoryBack } from '../../../hooks/useHistoryBack'; import { disableHistoryBack } from '../../../hooks/useHistoryBack';
import useConnectionStatus from '../../../hooks/useConnectionStatus';
import DropdownMenu from '../../ui/DropdownMenu'; import DropdownMenu from '../../ui/DropdownMenu';
import MenuItem from '../../ui/MenuItem'; import MenuItem from '../../ui/MenuItem';
@ -26,6 +28,8 @@ import Button from '../../ui/Button';
import SearchInput from '../../ui/SearchInput'; import SearchInput from '../../ui/SearchInput';
import PickerSelectedItem from '../../common/PickerSelectedItem'; import PickerSelectedItem from '../../common/PickerSelectedItem';
import Switcher from '../../ui/Switcher'; import Switcher from '../../ui/Switcher';
import ShowTransition from '../../ui/ShowTransition';
import ConnectionStatusOverlay from '../ConnectionStatusOverlay';
import './LeftMainHeader.scss'; import './LeftMainHeader.scss';
@ -40,16 +44,20 @@ type OwnProps = {
onReset: () => void; onReset: () => void;
}; };
type StateProps = { type StateProps =
searchQuery?: string; {
isLoading: boolean; searchQuery?: string;
currentUserId?: string; isLoading: boolean;
globalSearchChatId?: string; currentUserId?: string;
searchDate?: number; globalSearchChatId?: string;
theme: ISettings['theme']; searchDate?: number;
animationLevel: 0 | 1 | 2; theme: ISettings['theme'];
chatsById?: Record<string, ApiChat>; animationLevel: 0 | 1 | 2;
}; chatsById?: Record<string, ApiChat>;
isConnectionStatusMinimized: ISettings['isConnectionStatusMinimized'];
isMessageListOpen: boolean;
}
& Pick<GlobalState, 'connectionState' | 'isSyncing'>;
const ANIMATION_LEVEL_OPTIONS = [0, 1, 2]; const ANIMATION_LEVEL_OPTIONS = [0, 1, 2];
@ -74,6 +82,10 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
theme, theme,
animationLevel, animationLevel,
chatsById, chatsById,
connectionState,
isSyncing,
isConnectionStatusMinimized,
isMessageListOpen,
}) => { }) => {
const { const {
openChat, openChat,
@ -105,6 +117,10 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
}, 0); }, 0);
}, [hasMenu, chatsById]); }, [hasMenu, chatsById]);
const { connectionStatus, connectionStatusText, connectionStatusPosition } = useConnectionStatus(
lang, connectionState, isSyncing, isMessageListOpen, isConnectionStatusMinimized,
);
const withOtherVersions = window.location.hostname === PRODUCTION_HOSTNAME; const withOtherVersions = window.location.hostname === PRODUCTION_HOSTNAME;
const MainButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { const MainButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => {
@ -134,6 +150,10 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
} }
}, [searchQuery, onSearchQuery]); }, [searchQuery, onSearchQuery]);
const toggleConnectionStatus = useCallback(() => {
setSettingOption({ isConnectionStatusMinimized: !isConnectionStatusMinimized });
}, [isConnectionStatusMinimized, setSettingOption]);
const handleSelectSaved = useCallback(() => { const handleSelectSaved = useCallback(() => {
openChat({ id: currentUserId, shouldReplaceHistory: true }); openChat({ id: currentUserId, shouldReplaceHistory: true });
}, [currentUserId, openChat]); }, [currentUserId, openChat]);
@ -272,13 +292,16 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
className={globalSearchChatId || searchDate ? 'with-picker-item' : ''} className={globalSearchChatId || searchDate ? 'with-picker-item' : ''}
value={contactsFilter || searchQuery} value={contactsFilter || searchQuery}
focused={isSearchFocused} focused={isSearchFocused}
isLoading={isLoading} isLoading={isLoading || connectionStatusPosition === 'minimized'}
spinnerColor={connectionStatusPosition === 'minimized' ? 'yellow' : undefined}
spinnerBackgroundColor={connectionStatusPosition === 'minimized' && theme === 'light' ? 'light' : undefined}
placeholder={searchInputPlaceholder} placeholder={searchInputPlaceholder}
autoComplete="off" autoComplete="off"
canClose={Boolean(globalSearchChatId || searchDate)} canClose={Boolean(globalSearchChatId || searchDate)}
onChange={onSearchQuery} onChange={onSearchQuery}
onReset={onReset} onReset={onReset}
onFocus={handleSearchFocus} onFocus={handleSearchFocus}
onSpinnerClick={connectionStatusPosition === 'minimized' ? toggleConnectionStatus : undefined}
> >
{selectedSearchDate && ( {selectedSearchDate && (
<PickerSelectedItem <PickerSelectedItem
@ -300,6 +323,19 @@ const LeftMainHeader: FC<OwnProps & StateProps> = ({
/> />
)} )}
</SearchInput> </SearchInput>
<ShowTransition
isOpen={connectionStatusPosition === 'overlay'}
isCustom
className="connection-state-wrapper"
>
{() => (
<ConnectionStatusOverlay
connectionStatus={connectionStatus}
connectionStatusText={connectionStatusText!}
onClick={toggleConnectionStatus}
/>
)}
</ShowTransition>
</div> </div>
</div> </div>
); );
@ -310,9 +346,9 @@ export default memo(withGlobal<OwnProps>(
const { const {
query: searchQuery, fetchingStatus, chatId, date, query: searchQuery, fetchingStatus, chatId, date,
} = global.globalSearch; } = global.globalSearch;
const { currentUserId } = global; const { currentUserId, connectionState, isSyncing } = global;
const { byId: chatsById } = global.chats; const { byId: chatsById } = global.chats;
const { animationLevel } = global.settings.byKey; const { isConnectionStatusMinimized, animationLevel } = global.settings.byKey;
return { return {
searchQuery, searchQuery,
@ -323,6 +359,10 @@ export default memo(withGlobal<OwnProps>(
searchDate: date, searchDate: date,
theme: selectTheme(global), theme: selectTheme(global),
animationLevel, animationLevel,
connectionState,
isSyncing,
isConnectionStatusMinimized,
isMessageListOpen: Boolean(selectCurrentMessageList(global)),
}; };
}, },
)(LeftMainHeader)); )(LeftMainHeader));

View File

@ -552,7 +552,7 @@ const MessageList: FC<OwnProps & StateProps> = ({
onNotchToggle={onNotchToggle} onNotchToggle={onNotchToggle}
/> />
) : ( ) : (
<Loading color="white" /> <Loading color="white" backgroundColor="dark" />
)} )}
</div> </div>
); );

View File

@ -1,48 +1,41 @@
import React, { import React, {
FC, useCallback, useMemo, memo, useEffect, useRef, useState, FC, memo, useCallback, useEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getDispatch, getGlobal, withGlobal } from '../../lib/teact/teactn'; import { getDispatch, getGlobal, withGlobal } from '../../lib/teact/teactn';
import cycleRestrict from '../../util/cycleRestrict'; import cycleRestrict from '../../util/cycleRestrict';
import { MessageListType } from '../../global/types'; import { GlobalState, MessageListType } from '../../global/types';
import { import {
ApiMessage, ApiChat, ApiMessage, ApiTypingStatus, ApiUser, MAIN_THREAD_ID,
ApiChat,
ApiUser,
ApiTypingStatus,
MAIN_THREAD_ID, ApiUpdateConnectionStateType,
} from '../../api/types'; } from '../../api/types';
import { import {
MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN,
MOBILE_SCREEN_MAX_WIDTH,
EDITABLE_INPUT_ID, EDITABLE_INPUT_ID,
MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN,
MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN,
SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, MOBILE_SCREEN_MAX_WIDTH,
SAFE_SCREEN_WIDTH_FOR_CHAT_INFO, SAFE_SCREEN_WIDTH_FOR_CHAT_INFO,
SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN,
} from '../../config'; } from '../../config';
import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../util/environment'; import { IS_SINGLE_COLUMN_LAYOUT, IS_TABLET_COLUMN_LAYOUT } from '../../util/environment';
import { import {
isUserId, getChatTitle, getMessageKey, getSenderTitle, isUserId,
getMessageKey,
getChatTitle,
getSenderTitle,
} from '../../modules/helpers'; } from '../../modules/helpers';
import { import {
selectAllowedMessageActions,
selectChat, selectChat,
selectChatMessage, selectChatMessage,
selectAllowedMessageActions,
selectIsRightColumnShown,
selectThreadTopMessageId,
selectThreadInfo,
selectChatMessages, selectChatMessages,
selectPinnedIds,
selectIsChatWithSelf,
selectForwardedSender,
selectScheduledIds,
selectIsInSelectMode,
selectIsChatWithBot,
selectCountNotMutedUnread, selectCountNotMutedUnread,
selectForwardedSender,
selectIsChatWithBot,
selectIsChatWithSelf,
selectIsInSelectMode,
selectIsRightColumnShown,
selectPinnedIds,
selectScheduledIds,
selectThreadInfo,
selectThreadTopMessageId,
} from '../../modules/selectors'; } from '../../modules/selectors';
import useEnsureMessage from '../../hooks/useEnsureMessage'; import useEnsureMessage from '../../hooks/useEnsureMessage';
import useWindowSize from '../../hooks/useWindowSize'; import useWindowSize from '../../hooks/useWindowSize';
@ -51,7 +44,7 @@ import useCurrentOrPrev from '../../hooks/useCurrentOrPrev';
import { formatIntegerCompact } from '../../util/textFormat'; import { formatIntegerCompact } from '../../util/textFormat';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useBrowserOnline from '../../hooks/useBrowserOnline'; import useConnectionStatus from '../../hooks/useConnectionStatus';
import PrivateChatInfo from '../common/PrivateChatInfo'; import PrivateChatInfo from '../common/PrivateChatInfo';
import GroupChatInfo from '../common/GroupChatInfo'; import GroupChatInfo from '../common/GroupChatInfo';
@ -91,7 +84,8 @@ type StateProps = {
lastSyncTime?: number; lastSyncTime?: number;
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
currentTransitionKey: number; currentTransitionKey: number;
connectionState?: ApiUpdateConnectionStateType; connectionState?: GlobalState['connectionState'];
isSyncing?: GlobalState['isSyncing'];
}; };
const MiddleHeader: FC<OwnProps & StateProps> = ({ const MiddleHeader: FC<OwnProps & StateProps> = ({
@ -116,6 +110,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
currentTransitionKey, currentTransitionKey,
connectionState, connectionState,
isSyncing,
}) => { }) => {
const { const {
openChatWithInfo, openChatWithInfo,
@ -296,21 +291,9 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
} }
}, [shouldUseStackedToolsClass, canRevealTools, canToolsCollideWithChatInfo, isRightColumnShown]); }, [shouldUseStackedToolsClass, canRevealTools, canToolsCollideWithChatInfo, isRightColumnShown]);
const isBrowserOnline = useBrowserOnline(); const { connectionStatusText } = useConnectionStatus(lang, connectionState, isSyncing, true);
const isConnecting = (!isBrowserOnline || connectionState === 'connectionStateConnecting')
&& (IS_SINGLE_COLUMN_LAYOUT || (IS_TABLET_COLUMN_LAYOUT && !shouldShowCloseButton));
function renderInfo() { function renderInfo() {
if (isConnecting) {
return (
<>
{renderBackButton()}
<h3>
{lang('WaitingForNetwork')}
</h3>
</>
);
}
return ( return (
messageListType === 'thread' && threadId === MAIN_THREAD_ID ? ( messageListType === 'thread' && threadId === MAIN_THREAD_ID ? (
renderMainThreadInfo() renderMainThreadInfo()
@ -348,6 +331,8 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
<PrivateChatInfo <PrivateChatInfo
userId={chatId} userId={chatId}
typingStatus={typingStatus} typingStatus={typingStatus}
status={connectionStatusText}
withDots={Boolean(connectionStatusText)}
withFullInfo={isChatWithBot} withFullInfo={isChatWithBot}
withMediaViewer withMediaViewer
withUpdatingStatus withUpdatingStatus
@ -357,10 +342,12 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
<GroupChatInfo <GroupChatInfo
chatId={chatId} chatId={chatId}
typingStatus={typingStatus} typingStatus={typingStatus}
noRtl status={connectionStatusText}
withDots={Boolean(connectionStatusText)}
withMediaViewer withMediaViewer
withFullInfo withFullInfo
withUpdatingStatus withUpdatingStatus
noRtl
/> />
)} )}
</div> </div>
@ -395,7 +382,7 @@ const MiddleHeader: FC<OwnProps & StateProps> = ({
<div className="MiddleHeader" ref={componentRef}> <div className="MiddleHeader" ref={componentRef}>
<Transition <Transition
name={shouldSkipHistoryAnimations ? 'none' : 'slide-fade'} name={shouldSkipHistoryAnimations ? 'none' : 'slide-fade'}
activeKey={isConnecting ? Infinity : currentTransitionKey} activeKey={currentTransitionKey}
> >
{renderInfo} {renderInfo}
</Transition> </Transition>
@ -478,6 +465,7 @@ export default memo(withGlobal<OwnProps>(
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
currentTransitionKey: Math.max(0, global.messages.messageLists.length - 1), currentTransitionKey: Math.max(0, global.messages.messageLists.length - 1),
connectionState: global.connectionState, connectionState: global.connectionState,
isSyncing: global.isSyncing,
}; };
const messagesById = selectChatMessages(global, chatId); const messagesById = selectChatMessages(global, chatId);

View File

@ -200,6 +200,20 @@
} }
} }
&.translucent-black {
background-color: transparent;
color: rgba(0, 0, 0, 0.8);
--ripple-color: rgba(0, 0, 0, 0.08);
@include active-styles() {
background-color: rgba(0, 0, 0, 0.08);
}
@include no-ripple-styles() {
background-color: rgba(0, 0, 0, 0.16);
}
}
&.dark { &.dark {
background-color: rgba(0, 0, 0, 0.75); background-color: rgba(0, 0, 0, 0.75);
color: white; color: white;

View File

@ -16,7 +16,9 @@ export type OwnProps = {
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
children: any; children: any;
size?: 'default' | 'smaller' | 'tiny'; size?: 'default' | 'smaller' | 'tiny';
color?: 'primary' | 'secondary' | 'gray' | 'danger' | 'translucent' | 'translucent-white' | 'dark'; color?: (
'primary' | 'secondary' | 'gray' | 'danger' | 'translucent' | 'translucent-white' | 'translucent-black' | 'dark'
);
backgroundImage?: string; backgroundImage?: string;
className?: string; className?: string;
round?: boolean; round?: boolean;
@ -159,7 +161,7 @@ const Button: FC<OwnProps> = ({
<span dir={isRtl ? 'auto' : undefined}>Please wait...</span> <span dir={isRtl ? 'auto' : undefined}>Please wait...</span>
<Spinner color={isText ? 'blue' : 'white'} /> <Spinner color={isText ? 'blue' : 'white'} />
</div> </div>
) : children } ) : children}
{!disabled && ripple && ( {!disabled && ripple && (
<RippleEffect /> <RippleEffect />
)} )}

View File

@ -4,6 +4,10 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&.interactive {
cursor: pointer;
}
.Spinner { .Spinner {
--spinner-size: 2.75rem; --spinner-size: 2.75rem;
} }

View File

@ -1,17 +1,20 @@
import React, { FC, memo } from '../../lib/teact/teact'; import React, { FC, memo } from '../../lib/teact/teact';
import Spinner from './Spinner'; import Spinner from './Spinner';
import buildClassName from '../../util/buildClassName';
import './Loading.scss'; import './Loading.scss';
type OwnProps = { type OwnProps = {
color?: 'blue' | 'white' | 'black'; color?: 'blue' | 'white' | 'black' | 'yellow';
backgroundColor?: 'light' | 'dark';
onClick?: NoneToVoidFunction;
}; };
const Loading: FC<OwnProps> = ({ color = 'blue' }) => { const Loading: FC<OwnProps> = ({ color = 'blue', backgroundColor, onClick }) => {
return ( return (
<div className="Loading"> <div className={buildClassName('Loading', onClick && 'interactive')} onClick={onClick}>
<Spinner color={color} withBackground={color === 'white'} /> <Spinner color={color} backgroundColor={backgroundColor} />
</div> </div>
); );
}; };

View File

@ -10,6 +10,7 @@ import useInputFocusOnOpen from '../../hooks/useInputFocusOnOpen';
import Loading from './Loading'; import Loading from './Loading';
import Button from './Button'; import Button from './Button';
import ShowTransition from './ShowTransition';
import './SearchInput.scss'; import './SearchInput.scss';
@ -22,6 +23,8 @@ type OwnProps = {
value?: string; value?: string;
focused?: boolean; focused?: boolean;
isLoading?: boolean; isLoading?: boolean;
spinnerColor?: 'yellow';
spinnerBackgroundColor?: 'light';
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
autoComplete?: string; autoComplete?: string;
@ -31,6 +34,7 @@ type OwnProps = {
onReset?: NoneToVoidFunction; onReset?: NoneToVoidFunction;
onFocus?: NoneToVoidFunction; onFocus?: NoneToVoidFunction;
onBlur?: NoneToVoidFunction; onBlur?: NoneToVoidFunction;
onSpinnerClick?: NoneToVoidFunction;
}; };
const SearchInput: FC<OwnProps> = ({ const SearchInput: FC<OwnProps> = ({
@ -42,6 +46,8 @@ const SearchInput: FC<OwnProps> = ({
className, className,
focused, focused,
isLoading, isLoading,
spinnerColor,
spinnerBackgroundColor,
placeholder, placeholder,
disabled, disabled,
autoComplete, autoComplete,
@ -51,6 +57,7 @@ const SearchInput: FC<OwnProps> = ({
onReset, onReset,
onFocus, onFocus,
onBlur, onBlur,
onSpinnerClick,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
let inputRef = useRef<HTMLInputElement>(null); let inputRef = useRef<HTMLInputElement>(null);
@ -126,9 +133,11 @@ const SearchInput: FC<OwnProps> = ({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
/> />
<i className="icon-search" /> <i className="icon-search" />
{isLoading && ( <ShowTransition isOpen={Boolean(isLoading)} className="slow">
<Loading /> {() => (
)} <Loading color={spinnerColor} backgroundColor={spinnerBackgroundColor} onClick={onSpinnerClick} />
)}
</ShowTransition>
{!isLoading && (value || canClose) && onReset && ( {!isLoading && (value || canClose) && onReset && (
<Button <Button
round round

View File

@ -34,7 +34,14 @@
bottom: -0.125rem; bottom: -0.125rem;
right: -0.125rem; right: -0.125rem;
border-radius: 50%; border-radius: 50%;
background: rgba(0,0,0,0.25); }
&.bg-dark::before {
background: rgba(0, 0, 0, 0.25);
}
&.bg-light::before {
background: rgba(255, 255, 255, 0.25);
} }
} }
@ -53,6 +60,7 @@
&.blue { &.blue {
> div { > div {
background-image: var(--spinner-blue-data); background-image: var(--spinner-blue-data);
.theme-dark & { .theme-dark & {
background-image: var(--spinner-dark-blue-data); background-image: var(--spinner-dark-blue-data);
} }
@ -76,12 +84,19 @@
background-image: var(--spinner-gray-data); background-image: var(--spinner-gray-data);
} }
} }
&.yellow {
> div {
background-image: var(--spinner-yellow-data);
}
}
} }
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }

View File

@ -5,14 +5,14 @@ import buildClassName from '../../util/buildClassName';
import './Spinner.scss'; import './Spinner.scss';
const Spinner: FC<{ const Spinner: FC<{
color?: 'blue' | 'white' | 'black' | 'green' | 'gray'; color?: 'blue' | 'white' | 'black' | 'green' | 'gray' | 'yellow';
withBackground?: boolean; backgroundColor?: 'light' | 'dark';
}> = ({ }> = ({
color = 'blue', color = 'blue',
withBackground, backgroundColor,
}) => { }) => {
return ( return (
<div className={buildClassName('Spinner', color, withBackground && 'with-background')}> <div className={buildClassName('Spinner', color, backgroundColor && 'with-background', `bg-${backgroundColor}`)}>
<div /> <div />
</div> </div>
); );

View File

@ -168,6 +168,7 @@ export const INITIAL_STATE: GlobalState = {
language: 'en', language: 'en',
timeFormat: '24h', timeFormat: '24h',
wasTimeFormatSetManually: false, wasTimeFormatSetManually: false,
isConnectionStatusMinimized: false,
}, },
themes: { themes: {
light: { light: {

View File

@ -115,6 +115,7 @@ export type GlobalState = {
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
connectionState?: ApiUpdateConnectionStateType; connectionState?: ApiUpdateConnectionStateType;
currentUserId?: string; currentUserId?: string;
isSyncing?: boolean;
lastSyncTime?: number; lastSyncTime?: number;
serverTimeOffset: number; serverTimeOffset: number;
leftColumnWidth?: number; leftColumnWidth?: number;

View File

@ -0,0 +1,62 @@
import { GlobalState } from '../global/types';
import useBrowserOnline from './useBrowserOnline';
import { LangFn } from './useLang';
export enum ConnectionStatus {
waitingForNetwork,
syncing,
online,
}
type ConnectionStatusPosition =
'overlay'
| 'minimized'
| 'middleHeader'
| 'none';
export default function useConnectionStatus(
lang: LangFn,
connectionState: GlobalState['connectionState'],
isSyncing: GlobalState['isSyncing'],
hasMiddleHeader: boolean,
isMinimized?: boolean,
) {
let status: ConnectionStatus;
const isBrowserOnline = useBrowserOnline();
if (!isBrowserOnline || connectionState === 'connectionStateConnecting') {
status = ConnectionStatus.waitingForNetwork;
} else if (isSyncing) {
status = ConnectionStatus.syncing;
} else {
status = ConnectionStatus.online;
}
let position: ConnectionStatusPosition;
if (status === ConnectionStatus.online) {
position = 'none';
} else if (hasMiddleHeader) {
position = 'middleHeader';
} else if (isMinimized) {
position = 'minimized';
} else {
position = 'overlay';
}
let text: string | undefined;
if (status === ConnectionStatus.waitingForNetwork) {
text = lang('WaitingForNetwork');
} else if (status === ConnectionStatus.syncing) {
text = lang('Updating');
}
if (position === 'middleHeader') {
text = text!.toLowerCase().replace(/\.+$/, '');
}
return {
connectionStatus: status,
connectionStatusPosition: position,
connectionStatusText: text,
};
}

View File

@ -53,6 +53,8 @@ async function sync(afterSyncCallback: () => void) {
console.log('>>> START SYNC'); console.log('>>> START SYNC');
} }
setGlobal({ ...getGlobal(), isSyncing: true });
await callApi('fetchCurrentUser'); await callApi('fetchCurrentUser');
// This fetches only active chats and clears archived chats, which will be fetched in `afterSync` // This fetches only active chats and clears archived chats, which will be fetched in `afterSync`
@ -62,6 +64,7 @@ async function sync(afterSyncCallback: () => void) {
setGlobal({ setGlobal({
...getGlobal(), ...getGlobal(),
lastSyncTime: Date.now(), lastSyncTime: Date.now(),
isSyncing: false,
}); });
if (DEBUG) { if (DEBUG) {

View File

@ -218,6 +218,7 @@ $color-message-reaction-own-hover: #b5e0a4;
--spinner-black-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzJlMzkzOSIvPjwvc3ZnPg==); --spinner-black-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzJlMzkzOSIvPjwvc3ZnPg==);
--spinner-green-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzRmYWU0ZSIvPjwvc3ZnPg==); --spinner-green-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzRmYWU0ZSIvPjwvc3ZnPg==);
--spinner-gray-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzcwNzU3OSIvPjwvc3ZnPg==); --spinner-gray-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzcwNzU3OSIvPjwvc3ZnPg==);
--spinner-yellow-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iI0ZERDc2NCIvPjwvc3ZnPg==);
--drag-target-border: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='%23DDDFE0' stroke-width='4' stroke-dasharray='9.1%2c 10.5' stroke-dashoffset='3' stroke-linecap='round'/%3e%3c/svg%3e"); --drag-target-border: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='%23DDDFE0' stroke-width='4' stroke-dasharray='9.1%2c 10.5' stroke-dashoffset='3' stroke-linecap='round'/%3e%3c/svg%3e");
--drag-target-border-hovered: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='%2363A2E3' stroke-width='4' stroke-dasharray='9.1%2c 10.5' stroke-dashoffset='3' stroke-linecap='round'/%3e%3c/svg%3e"); --drag-target-border-hovered: url("data:image/svg+xml,%3csvg width='100%25' height='100%25' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='100%25' height='100%25' fill='none' rx='8' ry='8' stroke='%2363A2E3' stroke-width='4' stroke-dasharray='9.1%2c 10.5' stroke-dashoffset='3' stroke-linecap='round'/%3e%3c/svg%3e");

View File

@ -81,6 +81,7 @@ export interface ISettings extends NotifySettings, Record<string, any> {
canChangeSensitive?: boolean; canChangeSensitive?: boolean;
timeFormat: TimeFormat; timeFormat: TimeFormat;
wasTimeFormatSetManually: boolean; wasTimeFormatSetManually: boolean;
isConnectionStatusMinimized: boolean;
} }
export interface ApiPrivacySettings { export interface ApiPrivacySettings {