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;
}
type WindowWithPerf = typeof window & { perf: AnyLiteral };
type WindowWithPerf =
typeof window
& { perf: AnyLiteral };
interface TEncodedImage {
result: Uint8ClampedArray;
@ -70,10 +72,12 @@ interface IWebpWorker extends Worker {
interface Window {
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 {
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 VerifiedIcon from './VerifiedIcon';
import TypingStatus from './TypingStatus';
import DotAnimation from './DotAnimation';
type OwnProps = {
chatId: string;
typingStatus?: ApiTypingStatus;
avatarSize?: 'small' | 'medium' | 'large' | 'jumbo';
status?: string;
withDots?: boolean;
withMediaViewer?: boolean;
withUsername?: boolean;
withFullInfo?: boolean;
@ -44,6 +47,8 @@ type StateProps =
const GroupChatInfo: FC<OwnProps & StateProps> = ({
typingStatus,
avatarSize = 'medium',
status,
withDots,
withMediaViewer,
withUsername,
withFullInfo,
@ -86,9 +91,17 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
}
function renderStatusOrTyping() {
if (status) {
return withDots ? (
<DotAnimation className="status" content={status} />
) : (
<span className="status" dir="auto">{status}</span>
);
}
if (withUpdatingStatus && !areMessagesLoaded && !isRestricted) {
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 VerifiedIcon from './VerifiedIcon';
import TypingStatus from './TypingStatus';
import DotAnimation from './DotAnimation';
type OwnProps = {
userId: string;
@ -23,6 +24,7 @@ type OwnProps = {
avatarSize?: 'tiny' | 'small' | 'medium' | 'large' | 'jumbo';
forceShowSelf?: boolean;
status?: string;
withDots?: boolean;
withMediaViewer?: boolean;
withUsername?: boolean;
withFullInfo?: boolean;
@ -45,6 +47,7 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
typingStatus,
avatarSize = 'medium',
status,
withDots,
withMediaViewer,
withUsername,
withFullInfo,
@ -90,14 +93,16 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
function renderStatusOrTyping() {
if (status) {
return (
return withDots ? (
<DotAnimation className="status" content={status} />
) : (
<span className="status" dir="auto">{status}</span>
);
}
if (withUpdatingStatus && !areMessagesLoaded) {
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);
}
}
.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 useLang from '../../hooks/useLang';
import DotAnimation from './DotAnimation';
import './TypingStatus.scss';
type OwnProps = {
@ -21,15 +23,17 @@ type StateProps = {
const TypingStatus: FC<OwnProps & StateProps> = ({ typingStatus, typingUser }) => {
const lang = useLang();
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 (
<p className="typing-status" dir={lang.isRtl ? 'rtl' : 'auto'}>
{typingUserName && (
<span className="sender-name" dir="auto">{renderText(typingUserName)}</span>
)}
{/* fix for translation "username _is_ typing" */}
{lang(typingStatus.action).replace('{user}', '').replace('{emoji}', typingStatus.emoji).trim()}
<span className="ellipsis" />
<DotAnimation content={content} />
</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;
z-index: 1;
.connection-state-wrapper {
position: absolute;
top: 3.75rem;
width: 100%;
}
> .Transition {
flex: 1;
overflow: hidden;
transition: transform 300ms ease;
&.pull-down {
transform: translateY(3.75rem);
}
}
.ChatFolders {

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,9 @@ export type OwnProps = {
type?: 'button' | 'submit' | 'reset';
children: any;
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;
className?: string;
round?: boolean;
@ -159,7 +161,7 @@ const Button: FC<OwnProps> = ({
<span dir={isRtl ? 'auto' : undefined}>Please wait...</span>
<Spinner color={isText ? 'blue' : 'white'} />
</div>
) : children }
) : children}
{!disabled && ripple && (
<RippleEffect />
)}

View File

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

View File

@ -1,17 +1,20 @@
import React, { FC, memo } from '../../lib/teact/teact';
import Spinner from './Spinner';
import buildClassName from '../../util/buildClassName';
import './Loading.scss';
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 (
<div className="Loading">
<Spinner color={color} withBackground={color === 'white'} />
<div className={buildClassName('Loading', onClick && 'interactive')} onClick={onClick}>
<Spinner color={color} backgroundColor={backgroundColor} />
</div>
);
};

View File

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

View File

@ -34,7 +34,14 @@
bottom: -0.125rem;
right: -0.125rem;
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 {
> div {
background-image: var(--spinner-blue-data);
.theme-dark & {
background-image: var(--spinner-dark-blue-data);
}
@ -76,12 +84,19 @@
background-image: var(--spinner-gray-data);
}
}
&.yellow {
> div {
background-image: var(--spinner-yellow-data);
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}

View File

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

View File

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

View File

@ -115,6 +115,7 @@ export type GlobalState = {
shouldSkipHistoryAnimations?: boolean;
connectionState?: ApiUpdateConnectionStateType;
currentUserId?: string;
isSyncing?: boolean;
lastSyncTime?: number;
serverTimeOffset: 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');
}
setGlobal({ ...getGlobal(), isSyncing: true });
await callApi('fetchCurrentUser');
// 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({
...getGlobal(),
lastSyncTime: Date.now(),
isSyncing: false,
});
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-green-data: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjggMjIuNEM2IDIxLjkgMi4xIDE4IDEuNiAxMy4yLjkgNy4xIDUuNCAxLjkgMTEuMyAxLjVjLjQgMCAuNy0uMy43LS43IDAtLjQtLjQtLjgtLjgtLjhDNC44LjQtLjIgNS45IDAgMTIuNS4yIDE4LjYgNS40IDIzLjggMTEuNSAyNGM2LjYuMiAxMi00LjggMTIuNC0xMS4yIDAtLjQtLjMtLjgtLjgtLjgtLjQgMC0uNy4zLS43LjctLjMgNS45LTUuNSAxMC40LTExLjYgOS43eiIgZmlsbD0iIzRmYWU0ZSIvPjwvc3ZnPg==);
--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-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;
timeFormat: TimeFormat;
wasTimeFormatSetManually: boolean;
isConnectionStatusMinimized: boolean;
}
export interface ApiPrivacySettings {