Quellcode durchsuchen

Support reconnect and re-sync

painor vor 5 Jahren
Ursprung
Commit
750c28d802

+ 36 - 1
src/api/gramjs/client.ts

@@ -1,10 +1,16 @@
 <<<<<<< HEAD
+<<<<<<< HEAD
 import {
   TelegramClient, session, GramJsApi, MTProto,
 } from '../../lib/gramjs';
 =======
 import { TelegramClient, sessions, Api as GramJs } from '../../lib/gramjs';
 >>>>>>> 42589b8b... GramJS: Add `LocalStorageSession` with keys and hashes for all DCs
+=======
+import {
+  TelegramClient, sessions, Api as GramJs, connection,
+} from '../../lib/gramjs';
+>>>>>>> 48d2d818... Support reconnect and re-sync
 import { Logger as GramJsLogger } from '../../lib/gramjs/extensions';
 
 import { DEBUG } from '../../config';
@@ -33,7 +39,10 @@ GramJsLogger.setLevel(DEBUG ? 'debug' : 'warn');
 =======
 >>>>>>> 073c3e12... GramJS: Implement signup
 
+const gramJsUpdateEventBuilder = { build: (update: object) => update };
+
 let client: TelegramClient;
+let isConnected = false;
 
 export async function init(sessionId: string) {
   const session = new sessions.LocalStorageSession(sessionId);
@@ -44,7 +53,8 @@ export async function init(sessionId: string) {
     { useWSS: true } as any,
   );
 
-  client.addEventHandler(onGramJsUpdate, { build: (update: object) => update });
+  client.addEventHandler(onGramJsUpdate, gramJsUpdateEventBuilder);
+  client.addEventHandler(onUpdate, gramJsUpdateEventBuilder);
 
   try {
     if (DEBUG) {
@@ -77,8 +87,24 @@ export async function init(sessionId: string) {
   }
 }
 
+<<<<<<< HEAD
 export async function invokeRequest<T extends InstanceType<GramJsApi.AnyRequest>>(request: T) {
+=======
+function onUpdate(update: any) {
+  if (update instanceof connection.UpdateConnectionState) {
+    isConnected = update.state === connection.UpdateConnectionState.states.connected;
+  }
+}
+
+export async function invokeRequest<T extends GramJs.AnyRequest>(request: T, shouldHandleUpdates = false) {
+>>>>>>> 48d2d818... Support reconnect and re-sync
   if (DEBUG) {
+    if (!isConnected) {
+      // eslint-disable-next-line no-console
+      console.warn(`[GramJs/client] INVOKE ${request.className} ERROR: Client is not connected`);
+      return undefined;
+    }
+
     // eslint-disable-next-line no-console
     console.log(`[GramJs/client] INVOKE ${request.className}`);
   }
@@ -164,7 +190,16 @@ export function downloadAvatar(entity: MTProto.chat | MTProto.user, isBig = fals
   return client.downloadProfilePhoto(entity, isBig);
 }
 
+<<<<<<< HEAD
 export function downloadMessageImage(message: MTProto.message) {
   return client.downloadMedia(message, { sizeType: 'x' });
+=======
+export function downloadMedia(url: string) {
+  if (!isConnected) {
+    throw new Error('ERROR: Client is not connected');
+  }
+
+  return queuedDownloadMedia(client, url);
+>>>>>>> 48d2d818... Support reconnect and re-sync
 }
 >>>>>>> f70d85dd... Gram JS: Replace generated `tl/*` contents with runtime logic; TypeScript typings

+ 27 - 0
src/api/gramjs/onGramJsUpdate.ts

@@ -1,5 +1,10 @@
+<<<<<<< HEAD
 import { GramJsApi, gramJsApi, MTProto } from '../../lib/gramjs';
 import { OnApiUpdate } from './types';
+=======
+import { Api as GramJs, connection } from '../../lib/gramjs';
+import { OnApiUpdate } from '../types';
+>>>>>>> 48d2d818... Support reconnect and re-sync
 
 import { buildApiMessage, buildApiMessageFromShort, buildApiMessageFromShortChat } from './builders/messages';
 import { getApiChatIdFromMtpPeer } from './builders/chats';
@@ -15,12 +20,34 @@ export function init(_onUpdate: OnApiUpdate) {
   onUpdate = _onUpdate;
 }
 
+<<<<<<< HEAD
 export function onGramJsUpdate(update: AnyLiteral, originRequest?: InstanceType<GramJsApi.AnyRequest>) {
   if (
     update instanceof ctors.UpdateNewMessage
     || update instanceof ctors.UpdateShortChatMessage
     || update instanceof ctors.UpdateShortMessage
     // TODO UpdateNewChannelMessage
+=======
+export function onGramJsUpdate(update: GramJs.TypeUpdate | GramJs.TypeUpdates, originRequest?: GramJs.AnyRequest) {
+  if (update instanceof connection.UpdateConnectionState) {
+    const connectionState = update.state === connection.UpdateConnectionState.states.disconnected
+      ? 'connectionStateConnecting'
+      : 'connectionStateReady';
+
+    onUpdate({
+      '@type': 'updateConnectionState',
+      connection_state: {
+        '@type': connectionState,
+      },
+    });
+
+    // Messages
+  } else if (
+    update instanceof GramJs.UpdateNewMessage
+    || update instanceof GramJs.UpdateNewChannelMessage
+    || update instanceof GramJs.UpdateShortChatMessage
+    || update instanceof GramJs.UpdateShortMessage
+>>>>>>> 48d2d818... Support reconnect and re-sync
   ) {
     let message;
 

+ 21 - 0
src/api/types/updates.ts

@@ -15,6 +15,11 @@ export type ApiUpdateAuthorizationStateType = (
   'authorizationStateClosed'
 );
 
+export type ApiUpdateConnectionStateType = (
+  'connectionStateConnecting' |
+  'connectionStateReady'
+);
+
 export type ApiUpdateAuthorizationState = {
   '@type': 'updateAuthorizationState';
   authorization_state: {
@@ -23,6 +28,13 @@ export type ApiUpdateAuthorizationState = {
   session_id?: string;
 };
 
+export type ApiUpdateConnectionState = {
+  '@type': 'updateConnectionState';
+  connection_state: {
+    '@type': ApiUpdateConnectionStateType;
+  };
+};
+
 export type ApiUpdateChats = {
   '@type': 'chats';
   chats: ApiChat[];
@@ -75,9 +87,18 @@ export type ApiUpdateMessageImage = {
 };
 
 export type ApiUpdate = (
+<<<<<<< HEAD
   ApiUpdateAuthorizationState |
   ApiUpdateChats | ApiUpdateChat |
   ApiUpdateMessage | ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed |
   ApiUpdateUsers | ApiUpdateUser |
   ApiUpdateMessageImage
+=======
+  ApiUpdateAuthorizationState | ApiUpdateConnectionState |
+  ApiUpdateChats | ApiUpdateChat | ApiUpdateChatFullInfo |
+  ApiUpdateNewMessage | ApiUpdateEditMessage | ApiUpdateDeleteMessages |
+  ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed |
+  ApiUpdateUsers | ApiUpdateUser | ApiUpdateUserFullInfo |
+  ApiUpdateAvatar | ApiUpdateMessageImage
+>>>>>>> 48d2d818... Support reconnect and re-sync
 );

+ 1 - 0
src/assets/spinner-black.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 23C5.9 23 1 18.1 1 12S5.9 1 12 1h.1c.5 0 .8.4.8.8s-.4.8-.8.8H12c-5.1 0-9.3 4.2-9.3 9.3s4.2 9.3 9.3 9.3 9.3-4.2 9.3-9.3v-.1c0-.5.4-.8.8-.8s.8.4.8.8v.1C23 18.1 18.1 23 12 23z" fill="#2e3939"/></svg>

+ 42 - 0
src/components/Spinner.scss

@@ -0,0 +1,42 @@
+@-webkit-keyframes spin {
+  from {
+    transform: rotate(0deg);
+  } to {
+    transform: rotate(360deg);
+  }
+}
+
+@keyframes spin {
+  from {
+    transform: rotate(0deg);
+  } to {
+    transform: rotate(360deg);
+  }
+}
+
+.Spinner {
+  --spinner-size: 2rem;
+
+  display: block;
+  width: var(--spinner-size);
+  height: var(--spinner-size);
+
+  background-image: url('../assets/spinner-blue.svg');
+  background-repeat: no-repeat;
+  background-size: 100%;
+
+  animation-name: spin;
+  animation-duration: 1s;
+  animation-iteration-count: infinite;
+  animation-timing-function: linear;
+
+  &.white {
+    background-image: url('../assets/spinner-white.svg');
+  }
+  &.blue {
+    background-image: url('../assets/spinner-blue.svg');
+  }
+  &.black {
+    background-image: url('../assets/spinner-black.svg');
+  }
+}

+ 11 - 0
src/components/Spinner.tsx

@@ -0,0 +1,11 @@
+import React, { FC, memo } from '../lib/teact';
+
+import './Spinner.scss';
+
+const Spinner: FC<{ color?: 'blue' | 'white' | 'black' }> = ({ color = 'blue' }) => {
+  return (
+    <div className={`Spinner ${color}`} />
+  );
+};
+
+export default memo(Spinner);

+ 14 - 3
src/lib/gramjs/client/TelegramClient.js

@@ -12,6 +12,7 @@ const { LAYER } = require('../tl/AllTLObjects')
 const { constructors, requests } = require('../tl')
 const { computeCheck } = require('../Password')
 const MTProtoSender = require('../network/MTProtoSender')
+const { UpdateConnectionState } = require("../network")
 const { FloodWaitError } = require('../errors/RPCErrorList')
 const { ConnectionTCPObfuscated } = require('../network/connection/TCPObfuscated')
 
@@ -37,8 +38,8 @@ class TelegramClient {
         proxy: null,
         timeout: 10,
         requestRetries: 5,
-        connectionRetries: 5,
-        retryDelay: 1,
+        connectionRetries: Infinity,
+        retryDelay: 1000,
         autoReconnect: true,
         sequentialUpdates: false,
         floodSleepLimit: 60,
@@ -163,15 +164,18 @@ class TelegramClient {
             updateCallback: this._handleUpdate.bind(this),
 
         })
+
         const connection = new this._connection(this.session.serverAddress
             , this.session.port, this.session.dcId, this._log)
-        if (!await this._sender.connect(connection)) {
+        if (!await this._sender.connect(connection,this._dispatchUpdate.bind(this))) {
             return
         }
         this.session.setAuthKey(this._sender.authKey)
         await this._sender.send(this._initWith(
             new requests.help.GetConfigRequest({}),
         ))
+
+        this._dispatchUpdate({ update: new UpdateConnectionState(1) })
         this._updateLoop()
     }
 
@@ -1062,6 +1066,13 @@ class TelegramClient {
     }
 
     _handleUpdate(update) {
+        if (update === 1) {
+            this._dispatchUpdate({ update: new UpdateConnectionState(update) })
+            return
+        } else if (update === -1) {
+            this._dispatchUpdate({ update: new UpdateConnectionState(update) })
+            return
+        }
         this.session.processEntities(update)
         this._entityCache.add(update)
 

+ 8 - 3
src/lib/gramjs/extensions/MessagePacker.js

@@ -29,12 +29,17 @@ class MessagePacker {
     }
 
     async get() {
+
         if (!this._queue.length) {
             this._ready = new Promise(((resolve) => {
                 this.setReady = resolve
             }))
             await this._ready
         }
+        if (!this._queue[this._queue.length - 1]) {
+            this._queue = []
+            return
+        }
         let data
         let buffer = new BinaryWriter(Buffer.alloc(0))
 
@@ -50,7 +55,7 @@ class MessagePacker {
                     afterId = state.after.msgId
                 }
                 state.msgId = await this._state.writeDataAsMessage(
-                    buffer, state.data, state.request.classType==='request',
+                    buffer, state.data, state.request.classType === 'request',
                     afterId,
                 )
                 this._log.debug(`Assigned msgId = ${state.msgId} to ${state.request.className || state.request.constructor.name}`)
@@ -71,8 +76,8 @@ class MessagePacker {
         }
         if (batch.length > 1) {
             const b = Buffer.alloc(8)
-            b.writeUInt32LE(MessageContainer.CONSTRUCTOR_ID,0)
-            b.writeInt32LE(batch.length,4)
+            b.writeUInt32LE(MessageContainer.CONSTRUCTOR_ID, 0)
+            b.writeInt32LE(batch.length, 4)
             data = Buffer.concat([b, buffer.getValue()])
             buffer = new BinaryWriter(Buffer.alloc(0))
             const containerId = await this._state.writeDataAsMessage(

+ 11 - 6
src/lib/gramjs/extensions/PromisedWebSockets.js

@@ -1,4 +1,3 @@
-const BinaryReader = require('../extensions/BinaryReader')
 const Mutex = require('async-mutex').Mutex
 const mutex = new Mutex()
 
@@ -13,7 +12,6 @@ class PromisedWebSockets {
             process.browser === true ||
             process.__nwjs
         this.client = null
-
         this.closed = true
     }
 
@@ -34,8 +32,11 @@ class PromisedWebSockets {
             console.log('couldn\'t read')
             throw closeError
         }
-        const canWe = await this.canRead
-
+        await this.canRead
+        if (this.closed) {
+            console.log('couldn\'t read')
+            throw closeError
+        }
         const toReturn = this.stream.slice(0, number)
         this.stream = this.stream.slice(number)
         if (this.stream.length === 0) {
@@ -85,10 +86,14 @@ class PromisedWebSockets {
                 reject(error)
             }
             this.client.onclose = () => {
-                if (this.client.closed) {
                     this.resolveRead(false)
                     this.closed = true
-                }
+            }
+            if (this.isBrowser && typeof window !== 'undefined'){
+                window.addEventListener('offline', async () => {
+                    await this.close()
+                    this.resolveRead(false)
+                });
             }
         })
     }

+ 50 - 23
src/lib/gramjs/network/MTProtoSender.js

@@ -10,6 +10,7 @@ const RequestState = require('./RequestState')
 const { MsgsAck, upload, MsgsStateInfo, Pong } = require('../tl').constructors
 const MessagePacker = require('../extensions/MessagePacker')
 const BinaryReader = require('../extensions/BinaryReader')
+const { UpdateConnectionState } = require("./index");
 const { BadMessageError } = require("../errors/Common")
 const {
     BadServerSalt,
@@ -45,8 +46,8 @@ const { TypeNotFoundError } = require('../errors/Common')
 class MTProtoSender {
     static DEFAULT_OPTIONS = {
         logger: null,
-        retries: 5,
-        delay: 1,
+        retries: Infinity,
+        delay: 2000,
         autoReconnect: true,
         connectTimeout: null,
         authKeyCallback: null,
@@ -145,15 +146,30 @@ class MTProtoSender {
     /**
      * Connects to the specified given connection using the given auth key.
      * @param connection
+     * @param eventDispatch {function}
      * @returns {Promise<boolean>}
      */
-    async connect(connection) {
+    async connect(connection, eventDispatch=null) {
         if (this._user_connected) {
             this._log.info('User is already connected!')
             return false
         }
         this._connection = connection
-        await this._connect()
+
+        const retries = this._retries
+
+        for (let attempt = 0; attempt < retries; attempt++) {
+            try {
+                await this._connect()
+                break
+            } catch (e) {
+                if (attempt===0 && eventDispatch!==null){
+                    eventDispatch({ update: new UpdateConnectionState(-1) })
+                }
+                this._log.error("WebSocket connection failed attempt : "+(attempt+1))
+                await Helpers.sleep(this._delay)
+            }
+        }
         return true
     }
 
@@ -166,6 +182,7 @@ class MTProtoSender {
      * all pending requests, and closes the send and receive loops.
      */
     async disconnect() {
+
         await this._disconnect()
     }
 
@@ -236,7 +253,7 @@ class MTProtoSender {
              * switch to different data centers.
              */
             if (this._authKeyCallback) {
-                await this._authKeyCallback(this.authKey,this._dcId)
+                await this._authKeyCallback(this.authKey, this._dcId)
             }
         } else {
             this._log.debug('Already have an auth key ...')
@@ -262,6 +279,9 @@ class MTProtoSender {
             this._log.info('Not disconnecting (already have no connection)')
             return
         }
+        if (this._updateCallback){
+            this._updateCallback(-1)
+        }
         this._log.info('Disconnecting from %s...'.replace('%s', this._connection.toString()))
         this._user_connected = false
         this._log.debug('Closing current connection...')
@@ -276,6 +296,8 @@ class MTProtoSender {
      * @private
      */
     async _sendLoop() {
+        this._send_queue = new MessagePacker(this._state, this._log)
+
         while (this._user_connected && !this._reconnecting) {
             if (this._pending_ack.size) {
                 const ack = new RequestState(new MsgsAck({ msgIds: Array(...this._pending_ack) }))
@@ -283,14 +305,14 @@ class MTProtoSender {
                 this._last_acks.push(ack)
                 this._pending_ack.clear()
             }
-            this._log.debug('Waiting for messages to send...')
+            this._log.debug('Waiting for messages to send...'+this._reconnecting)
             // TODO Wait for the connection send queue to be empty?
             // This means that while it's not empty we can wait for
             // more messages to be added to the send queue.
             const res = await this._send_queue.get()
 
             if (this._reconnecting) {
-                return;
+                return
             }
 
             if (!res) {
@@ -338,6 +360,7 @@ class MTProtoSender {
             } catch (e) {
                 // this._log.info('Connection closed while receiving data');
                 this._log.warn('Connection closed while receiving data')
+                this._startReconnect()
                 return
             }
             try {
@@ -353,14 +376,7 @@ class MTProtoSender {
                     // A step while decoding had the incorrect data. This message
                     // should not be considered safe and it should be ignored.
                     this._log.warn(`Security error while unpacking a received message: ${e}`)
-
-                    // TODO Reconnecting does not work properly: all subsequent requests hang.
-                    // this.authKey.key = null
-                    // if (this._authKeyCallback) {
-                    //     await this._authKeyCallback(null)
-                    // }
-                    // this._startReconnect()
-                    // return
+                    continue
                 } else if (e instanceof InvalidBufferError) {
                     this._log.info('Broken authorization key; resetting')
                     await this.authKey.setKey(null)
@@ -368,11 +384,12 @@ class MTProtoSender {
                     if (this._authKeyCallback) {
                         await this._authKeyCallback(null)
                     }
-
-                    this._startReconnect()
+                    await this.disconnect()
                     return
                 } else {
                     this._log.error('Unhandled error while receiving data')
+                    console.log(e)
+                    this._startReconnect()
                     return
                 }
             }
@@ -600,8 +617,8 @@ class MTProtoSender {
             this._state._sequence -= 16
         } else {
 
-            for (const state of states){
-                state.reject(new BadMessageError(state.request,badMsg.errorCode))
+            for (const state of states) {
+                state.reject(new BadMessageError(state.request, badMsg.errorCode))
             }
 
             return
@@ -733,30 +750,40 @@ class MTProtoSender {
             this._reconnecting = true
             // TODO Should we set this?
             // this._user_connected = false
-            await this._reconnect()
+            this._log.info("Started reconnecting")
+            this._reconnect()
         }
     }
 
     async _reconnect() {
         this._log.debug('Closing current connection...')
         try {
-            await this._connection.disconnect()
+            await this.disconnect()
         } catch (err) {
             console.warn(err)
         }
+        this._send_queue.append(null)
+
         this._state.reset()
         const retries = this._retries
+
+
         for (let attempt = 0; attempt < retries; attempt++) {
             try {
                 await this._connect()
-                this._send_queue.extend(Object.values(this._pending_state))
+                // uncomment this if you want to resend
+                //this._send_queue.extend(Object.values(this._pending_state))
                 this._pending_state = {}
                 if (this._autoReconnectCallback) {
                     await this._autoReconnectCallback()
                 }
+                if (this._updateCallback){
+                    this._updateCallback(1)
+                }
+
                 break
             } catch (e) {
-                this._log.error(e)
+                this._log.error("WebSocket connection failed attempt : "+(attempt+1))
                 await Helpers.sleep(this._delay)
             }
         }

+ 12 - 3
src/lib/gramjs/network/connection/Connection.js

@@ -35,7 +35,7 @@ class Connection {
     async _connect() {
         this._log.debug('Connecting')
         this._codec = new this.PacketCodecClass(this)
-        await this.socket.connect(this._port, this._ip,this)
+        await this.socket.connect(this._port, this._ip, this)
         this._log.debug('Finished connecting')
         // await this.socket.connect({host: this._ip, port: this._port});
         await this._initConn()
@@ -44,6 +44,7 @@ class Connection {
     async connect() {
         await this._connect()
         this._connected = true
+
         if (!this._sendTask) {
             this._sendTask = this._sendLoop()
         }
@@ -52,6 +53,7 @@ class Connection {
 
     async disconnect() {
         this._connected = false
+        await this._recvArray.push(null)
         await this.socket.close()
     }
 
@@ -78,6 +80,10 @@ class Connection {
         try {
             while (this._connected) {
                 const data = await this._sendArray.pop()
+                if (!data) {
+                    this._sendTask = null
+                    return
+                }
                 await this._send(data)
             }
         } catch (e) {
@@ -92,11 +98,14 @@ class Connection {
             try {
                 data = await this._recv()
                 if (!data) {
-                    return
+                    throw new Error("no data recieved")
                 }
             } catch (e) {
                 console.log(e)
-                this._log.info('an error occured')
+                this._log.info('connection closed')
+                //await this._recvArray.push()
+
+                this.disconnect()
                 return
             }
             await this._recvArray.push(data)

+ 31 - 0
src/lib/gramjs/network/index.js

@@ -0,0 +1,31 @@
+const MTProtoPlainSender = require('./MTProtoPlainSender')
+const doAuthentication = require('./Authenticator')
+const MTProtoSender = require('./MTProtoSender')
+
+class UpdateConnectionState {
+    static states = {
+        disconnected: -1,
+        connected: 1
+    }
+
+    constructor(state) {
+        this.state = state
+    }
+}
+
+const {
+    Connection,
+    ConnectionTCPFull,
+    ConnectionTCPAbridged,
+    ConnectionTCPObfuscated,
+} = require('./connection')
+module.exports = {
+    Connection,
+    ConnectionTCPFull,
+    ConnectionTCPAbridged,
+    ConnectionTCPObfuscated,
+    MTProtoPlainSender,
+    doAuthentication,
+    MTProtoSender,
+    UpdateConnectionState,
+}

+ 28 - 0
src/lib/gramjs/tl/gramJsApi.js

@@ -39,6 +39,34 @@ const struct = require('python-struct')
 const { readBufferFromBigInt } = require('../Helpers')
 
 function buildApiFromTlSchema() {
+<<<<<<< HEAD:src/lib/gramjs/tl/gramJsApi.js
+=======
+    let definitions;
+    const fromCache = CACHING_SUPPORTED && loadFromCache()
+
+    if (fromCache) {
+        definitions = fromCache
+    } else {
+        definitions = loadFromTlSchemas()
+
+        if (CACHING_SUPPORTED) {
+            localStorage.setItem(CACHE_KEY, JSON.stringify(definitions))
+        }
+    }
+
+    return mergeWithNamespaces(
+      createClasses('constructor', definitions.constructors),
+      createClasses('request', definitions.requests)
+    )
+}
+
+function loadFromCache() {
+    const jsonCache = localStorage.getItem(CACHE_KEY)
+    return jsonCache && JSON.parse(jsonCache)
+}
+
+function loadFromTlSchemas() {
+>>>>>>> 48d2d818... Support reconnect and re-sync:src/lib/gramjs/tl/api.js
     const tlContent = readFileSync('./static/api.tl', 'utf-8')
     const [constructorParamsApi, functionParamsApi] = extractParams(tlContent)
     const schemeContent = readFileSync('./static/schema.tl', 'utf-8')

+ 0 - 1
src/modules/gramjs/actions/system.ts

@@ -11,7 +11,6 @@ addReducer('init', (global: GlobalState) => {
 
   return {
     ...global,
-    isInitialized: true,
     authIsSessionRemembered: Boolean(sessionId),
   };
 });

+ 91 - 0
src/modules/gramjs/updaters/system.ts

@@ -0,0 +1,91 @@
+import { getDispatch, getGlobal, setGlobal } from '../../../lib/teactn';
+
+import {
+  ApiUpdate,
+  ApiUpdateAuthorizationState,
+  ApiUpdateConnectionState,
+} from '../../../api/types';
+
+export function onUpdate(update: ApiUpdate) {
+  switch (update['@type']) {
+    case 'updateAuthorizationState':
+      onUpdateAuthorizationState(update);
+      break;
+
+    case 'updateConnectionState':
+      onUpdateConnectionState(update);
+      break;
+  }
+}
+
+function onUpdateAuthorizationState(update: ApiUpdateAuthorizationState) {
+  const currentState = getGlobal().authState;
+  const authState = update.authorization_state['@type'];
+  let authError;
+
+  if (currentState === 'authorizationStateWaitCode' && authState === 'authorizationStateWaitCode') {
+    authError = 'Invalid Code';
+  } else if (currentState === 'authorizationStateWaitPassword' && authState === 'authorizationStateWaitPassword') {
+    authError = 'Invalid Password';
+  }
+
+  setGlobal({
+    ...getGlobal(),
+    authState,
+    authIsLoading: false,
+    authError,
+  });
+
+  switch (authState) {
+    case 'authorizationStateLoggingOut':
+      setGlobal({
+        ...getGlobal(),
+        isLoggingOut: true,
+      });
+      break;
+    case 'authorizationStateWaitPhoneNumber':
+      break;
+    case 'authorizationStateWaitCode':
+      break;
+    case 'authorizationStateWaitPassword':
+      break;
+    case 'authorizationStateWaitRegistration':
+      break;
+    case 'authorizationStateReady': {
+      const { session_id } = update;
+      if (session_id && getGlobal().authRememberMe) {
+        getDispatch().saveSession({ sessionId: session_id });
+      }
+
+      getDispatch().sync();
+
+      setGlobal({
+        ...getGlobal(),
+        isLoggingOut: false,
+      });
+
+      break;
+    }
+    default:
+      break;
+  }
+}
+
+function onUpdateConnectionState(update: ApiUpdateConnectionState) {
+  const connectionState = update.connection_state['@type'];
+
+  setGlobal({
+    ...getGlobal(),
+    connectionState,
+  });
+
+  switch (connectionState) {
+    case 'connectionStateReady': {
+      getDispatch().sync();
+
+      break;
+    }
+    default:
+      break;
+  }
+}

+ 140 - 0
src/modules/tdlib/actions/system.ts

@@ -0,0 +1,140 @@
+import { addReducer, getGlobal, setGlobal } from '../../../lib/teactn';
+
+import { TDLIB_SESSION_ID_KEY } from '../../../config';
+import { GlobalState } from '../../../store/types';
+import * as TdLib from '../../../api/tdlib';
+import onUpdate from '../updaters';
+
+addReducer('init', (global: GlobalState) => {
+  const sessionId = localStorage.getItem(TDLIB_SESSION_ID_KEY) || '';
+  TdLib.init(onUpdate);
+
+  return {
+    ...global,
+    authIsSessionRemembered: Boolean(sessionId),
+  };
+});
+
+addReducer('setAuthPhoneNumber', (global, actions, payload) => {
+  const { phoneNumber } = payload!;
+
+  void setAuthPhoneNumber(phoneNumber);
+});
+
+addReducer('setAuthCode', (global, actions, payload) => {
+  const { code } = payload!;
+
+  void setAuthCode(code);
+});
+
+addReducer('setAuthPassword', (global, actions, payload) => {
+  const { password } = payload!;
+
+  void setAuthPassword(password);
+});
+
+addReducer('signUp', (global, actions, payload) => {
+  const { firstName, lastName } = payload!;
+
+  void signUp(firstName, lastName);
+});
+
+addReducer('signOut', () => {
+  void TdLib.send({ '@type': 'logOut' });
+
+  localStorage.removeItem(TDLIB_SESSION_ID_KEY);
+});
+
+async function setAuthPhoneNumber(phoneNumber: string) {
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: true,
+    authError: undefined,
+  });
+
+  await TdLib.send({
+    '@type': 'setAuthenticationPhoneNumber',
+    phone_number: phoneNumber,
+  }, () => {
+    setGlobal({
+      ...getGlobal(),
+      authError: 'Try Again Later',
+    });
+  });
+
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: false,
+  });
+}
+
+async function setAuthCode(code: string) {
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: true,
+    authError: undefined,
+  });
+
+  await TdLib.send({
+    '@type': 'checkAuthenticationCode',
+    code,
+  }, () => {
+    setGlobal({
+      ...getGlobal(),
+      authError: 'Invalid Code',
+    });
+  });
+
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: false,
+  });
+}
+
+async function setAuthPassword(password: string) {
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: true,
+    authError: undefined,
+  });
+
+  await TdLib.send({
+    '@type': 'checkAuthenticationPassword',
+    password,
+  }, () => {
+    setGlobal({
+      ...getGlobal(),
+      authError: 'Invalid Password',
+    });
+  });
+
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: false,
+  });
+}
+
+async function signUp(firstName: string, lastName: string) {
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: true,
+    authError: undefined,
+  });
+
+  // TODO Support avatar.
+  await TdLib.send({
+    '@type': 'registerUser',
+    first_name: firstName,
+    last_name: lastName,
+  }, () => {
+    setGlobal({
+      ...getGlobal(),
+      authError: 'Registration Error',
+    });
+  });
+
+  setGlobal({
+    ...getGlobal(),
+    authIsLoading: false,
+  });
+}

+ 26 - 0
src/pages/main/components/left/ConnectionState.scss

@@ -0,0 +1,26 @@
+#ConnectionState {
+  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: 600;
+    line-height: 2rem;
+    margin-left: 1.9rem;
+    white-space: nowrap;
+  }
+
+  @media (max-width: 950px) {
+    > .state-text {
+      margin-left: 1.2rem;
+    }
+  }
+}

+ 26 - 0
src/pages/main/components/left/ConnectionState.tsx

@@ -0,0 +1,26 @@
+import React, { FC } from '../../../../lib/teact';
+import { withGlobal } from '../../../../lib/teactn';
+import { GlobalState } from '../../../../store/types';
+
+import Spinner from '../../../../components/Spinner';
+
+import './ConnectionState.scss';
+
+type IProps = Pick<GlobalState, 'connectionState'>;
+
+const ConnectionState: FC<IProps> = ({ connectionState }) => {
+  const isConnecting = connectionState === 'connectionStateConnecting';
+  return isConnecting && (
+    <div id="ConnectionState">
+      <Spinner color="black" />
+      <div className="state-text">Waiting for network...</div>
+    </div>
+  );
+};
+
+export default withGlobal(
+  (global => {
+    const { connectionState } = global;
+    return { connectionState };
+  }),
+)(ConnectionState);

+ 19 - 0
src/pages/main/components/left/LeftColumn.tsx

@@ -0,0 +1,19 @@
+import React, { FC } from '../../../../lib/teact';
+
+import LeftHeader from './LeftHeader';
+import ConnectionState from './ConnectionState';
+import ChatList from './ChatList';
+
+import './LeftColumn.scss';
+
+const LeftColumn: FC = () => {
+  return (
+    <div id="LeftColumn">
+      <LeftHeader />
+      <ConnectionState />
+      <ChatList />
+    </div>
+  );
+};
+
+export default LeftColumn;

+ 139 - 0
src/store/index.ts

@@ -0,0 +1,139 @@
+import {
+  addCallback, addReducer, removeCallback, setGlobal,
+} from '../lib/teactn';
+
+import { GlobalState } from './types';
+
+import { pause, throttle } from '../util/schedulers';
+import { GLOBAL_STATE_CACHE_DISABLED, GLOBAL_STATE_CACHE_KEY, GRAMJS_SESSION_ID_KEY } from '../config';
+import { getChatAvatarHash } from '../modules/helpers';
+import * as mediaLoader from '../util/mediaLoader';
+
+const INITIAL_STATE: GlobalState = {
+  showRightColumn: true,
+
+  users: {
+    byId: {},
+  },
+
+  chats: {
+    ids: null,
+    byId: {},
+    scrollOffsetById: {},
+    replyingToById: {},
+  },
+
+  groups: {
+    ids: [],
+    byId: {},
+  },
+
+  messages: {
+    byChatId: {},
+  },
+
+  authRememberMe: true,
+};
+const CACHE_THROTTLE_TIMEOUT = 1000;
+const MAX_PRELOAD_DELAY = 1000;
+
+const updateCacheThrottled = throttle(updateCache, CACHE_THROTTLE_TIMEOUT, false);
+
+addReducer('init', () => {
+  setGlobal(INITIAL_STATE);
+
+  const hasActiveSession = localStorage.getItem(GRAMJS_SESSION_ID_KEY);
+  if (!GLOBAL_STATE_CACHE_DISABLED && hasActiveSession) {
+    const cached = getCache();
+
+    if (cached) {
+      preloadAssets(cached)
+        .then(() => {
+          setGlobal(cached);
+        });
+    }
+
+    addCallback(updateCacheThrottled);
+  }
+});
+
+if (!GLOBAL_STATE_CACHE_DISABLED) {
+  addReducer('saveSession', () => {
+    addCallback(updateCacheThrottled);
+  });
+
+  addReducer('signOut', () => {
+    removeCallback(updateCacheThrottled);
+    localStorage.removeItem(GLOBAL_STATE_CACHE_KEY);
+  });
+}
+
+function preloadAssets(cached: GlobalState) {
+  return Promise.race([
+    pause(MAX_PRELOAD_DELAY),
+    Promise.all(
+      Object.values(cached.chats.byId).map((chat) => {
+        const avatarHash = getChatAvatarHash(chat);
+        return avatarHash ? mediaLoader.fetch(avatarHash, mediaLoader.Type.DataUri) : null;
+      }),
+    ),
+  ]);
+}
+
+function updateCache(global: GlobalState) {
+  if (global.isLoggingOut) {
+    return;
+  }
+
+  const reducedState: GlobalState = {
+    ...global,
+    chats: reduceChatsForCache(global),
+    messages: reduceMessagesForCache(global),
+    connectionState: undefined,
+    // TODO Reduce `users` and `groups`?
+  };
+
+  const json = JSON.stringify(reducedState);
+  localStorage.setItem(GLOBAL_STATE_CACHE_KEY, json);
+}
+
+function reduceChatsForCache(global: GlobalState) {
+  const byId: GlobalState['chats']['byId'] = {};
+  const scrollOffsetById: GlobalState['chats']['scrollOffsetById'] = {};
+  const replyingToById: GlobalState['chats']['replyingToById'] = {};
+
+  if (global.chats.ids) {
+    global.chats.ids.forEach((id) => {
+      byId[id] = global.chats.byId[id];
+      scrollOffsetById[id] = global.chats.scrollOffsetById[id];
+      replyingToById[id] = global.chats.replyingToById[id];
+    });
+  }
+
+  return {
+    ...global.chats,
+    byId,
+    scrollOffsetById,
+    replyingToById,
+  };
+}
+
+function reduceMessagesForCache(global: GlobalState) {
+  const byChatId: GlobalState['messages']['byChatId'] = {};
+
+  if (global.chats.ids) {
+    global.chats.ids.forEach((chatId) => {
+      byChatId[chatId] = global.messages.byChatId[chatId];
+    });
+  }
+
+  return {
+    ...global.messages,
+    byChatId,
+  };
+}
+
+function getCache(): GlobalState | null {
+  const json = localStorage.getItem(GLOBAL_STATE_CACHE_KEY);
+  return json ? JSON.parse(json) : null;
+}

+ 64 - 0
src/store/types.ts

@@ -0,0 +1,64 @@
+import {
+  ApiChat,
+  ApiMessage,
+  ApiUser,
+  ApiGroup,
+  ApiUpdateAuthorizationStateType,
+  ApiUpdateConnectionStateType,
+} from '../api/types';
+
+export type GlobalState = {
+  showRightColumn: boolean;
+
+  users: {
+    byId: Record<number, ApiUser>;
+    selectedId?: number;
+  };
+
+  chats: {
+    selectedId?: number;
+    ids: number[] | null;
+    byId: Record<number, ApiChat>;
+    scrollOffsetById: Record<number, number>;
+    replyingToById: Record<number, number>;
+  };
+
+  groups: {
+    ids: number[];
+    byId: Record<number, ApiGroup>;
+  };
+
+  messages: {
+    selectedMediaMessageId?: number;
+    byChatId: Record<number, {
+      byId: Record<number, ApiMessage>;
+    }>;
+  };
+
+  // TODO Move to `auth`.
+  isLoggingOut?: boolean;
+  authState?: ApiUpdateAuthorizationStateType;
+  authPhoneNumber?: string;
+  authIsLoading?: boolean;
+  authError?: string;
+  authRememberMe?: boolean;
+  authIsSessionRemembered?: boolean;
+
+  connectionState?: ApiUpdateConnectionStateType;
+};
+
+export type ActionTypes = (
+  // system
+  'init' | 'setAuthPhoneNumber' | 'setAuthCode' | 'setAuthPassword' | 'signUp' | 'returnToAuthPhoneNumber' | 'signOut' |
+  'setAuthRememberMe' | 'toggleRightColumn' | 'saveSession' | 'sync' |
+  // chats
+  'loadChats' | 'loadMoreChats' | 'openChat' | 'openChatWithInfo' | 'setChatScrollOffset' | 'setChatReplyingTo' |
+  'loadFullChat' | 'loadChatOnlines' |
+  // messages
+  'loadChatMessages' | 'loadMoreChatMessages' | 'selectMessage' | 'sendTextMessage' | 'pinMessage' | 'deleteMessages' |
+  'selectMediaMessage' |
+  // users
+  'loadFullUser' | 'openUserInfo'
+);
+
+export type GlobalActions = Record<ActionTypes, Function>;

+ 80 - 0
src/styles/_variables.scss

@@ -0,0 +1,80 @@
+@function toRGB($color) {
+  @return red($color) + ", " + green($color) + ", " + blue($color);
+}
+
+$color-primary: #4ea4f6;
+$color-primary-shade: #51aaff;
+$color-primary-shade-2: #4b9dec;
+
+$color-links: #52a1ef;
+
+$color-text-green: #4fae4e;
+$color-green: #4dcd5e;
+
+$color-error: #e53935;
+
+$color-warning: #fb8c00;
+
+$color-yellow: #FDD764;
+
+$color-white: #ffffff;
+$color-black: #000000;
+$color-dark-gray: #2e3939;
+$color-gray: #c4c9cc;
+$color-text-secondary: #707579;
+$color-text-meta: #686c72;
+$color-borders: #ecedef;
+$color-chat-hover: #f4f4f5;
+
+:root {
+  --color-background: #{$color-white};
+  --color-text: #{$color-black};
+  --color-text-lighter: #{$color-dark-gray};
+  --color-text-secondary: #{$color-text-secondary};
+  --color-text-secondary-rgb: #{toRGB($color-text-secondary)};
+  --color-text-meta: #{$color-text-meta};
+  --color-text-green: #{$color-text-green};
+  --color-text-green-rgb: #{toRGB($color-text-green)};
+  --color-borders: #{$color-borders};
+
+  --color-primary: #{$color-primary};
+  --color-primary-shade: #{mix($color-primary, $color-black, 92%)};
+
+  --color-green: #{$color-green};
+
+  --color-error: #{$color-error};
+
+  --color-warning: #{$color-warning};
+
+  --color-yellow: #{$color-yellow};
+
+  --color-links: #{$color-links};
+  --color-links-hover: #{darken($color-links, 8%)};
+
+  --color-code: #{mix($color-links, $color-text-secondary, 50%)};
+  --color-code-own: #{mix($color-text-green, $color-text-secondary, 50%)};
+
+  --color-gray: #{$color-gray};
+
+  --color-chat-hover: #{$color-chat-hover};
+  --color-chat-active: #{$color-borders};
+
+  --border-radius-default: 0.5rem;
+  --border-radius-messages: 0.75rem;
+  --border-radius-messages-small: 0.375rem;
+  --messages-container-width: 45.5rem;
+
+  --z-modal: 1000;
+  --z-media-viewer: 100;
+  --z-animation-fade: 50;
+  --z-menu-bubble: 21;
+  --z-menu-backdrop: 20;
+  --z-message-highlighted: 12;
+  --z-message-context-menu: 11;
+  --z-message-date-header: 10;
+  --z-country-code-input-group: 10;
+  --z-layout-left-header: 5;
+  --z-register-add-avatar: 5;
+  --z-message-avatar: 2;
+  --z-below: -1;
+}