tweb/src/lib/appManagers/appStateManager.ts
morethanwords ca1213c32f Support noforwards
Fix chat date blinking
Fix displaying sent messages to new dialog
Scroll to date bubble if message is bigger than viewport
Fix releasing keyboard by inline helper
Fix clearing self user
Fix displaying sent public poll
Update contacts counter in dialogs placeholder
Improve multiselect animation
Disable lottie icon animations if they're disabled
Fix changing mtproto transport during authorization
2022-01-08 16:52:14 +04:00

558 lines
16 KiB
TypeScript

/*
* https://github.com/morethanwords/tweb
* Copyright (C) 2019-2021 Eduard Kuzmenko
* https://github.com/morethanwords/tweb/blob/master/LICENSE
*/
import type { Dialog } from './appMessagesManager';
import { NULL_PEER_ID, UserAuth } from '../mtproto/mtproto_config';
import type { MyTopPeer, TopPeerType, User } from './appUsersManager';
import type { AuthState } from '../../types';
import type FiltersStorage from '../storages/filters';
import type DialogsStorage from '../storages/dialogs';
import EventListenerBase from '../../helpers/eventListenerBase';
import rootScope from '../rootScope';
import stateStorage from '../stateStorage';
import { logger } from '../logger';
import { copy, setDeepProperty, validateInitObject } from '../../helpers/object';
import App from '../../config/app';
import DEBUG, { MOUNT_CLASS_TO } from '../../config/debug';
import AppStorage from '../storage';
import { Chat } from '../../layer';
import { IS_MOBILE } from '../../environment/userAgent';
import DATABASE_STATE from '../../config/databases/state';
import sessionStorage from '../sessionStorage';
import { nextRandomUint } from '../../helpers/random';
import compareVersion from '../../helpers/compareVersion';
const REFRESH_EVERY = 24 * 60 * 60 * 1000; // 1 day
// const REFRESH_EVERY = 1e3;
//const REFRESH_EVERY_WEEK = 24 * 60 * 60 * 1000 * 7; // 7 days
const STATE_VERSION = App.version;
const BUILD = App.build;
export type Background = {
type: 'color' | 'image' | 'default',
blur: boolean,
highlightningColor?: string,
color?: string,
slug?: string,
};
export type Theme = {
name: 'day' | 'night' | 'system',
background: Background
};
export type State = {
allDialogsLoaded: DialogsStorage['allDialogsLoaded'],
pinnedOrders: DialogsStorage['pinnedOrders'],
contactsList: UserId[],
updates: Partial<{
seq: number,
pts: number,
date: number
}>,
filters: FiltersStorage['filters'],
maxSeenMsgId: number,
stateCreatedTime: number,
recentEmoji: string[],
topPeersCache: {
[type in TopPeerType]?: {
peers: MyTopPeer[],
cachedTime: number
}
},
recentSearch: PeerId[],
version: typeof STATE_VERSION,
build: typeof BUILD,
authState: AuthState,
hiddenPinnedMessages: {[peerId: PeerId]: number},
settings: {
messagesTextSize: number,
sendShortcut: 'enter' | 'ctrlEnter',
animationsEnabled: boolean,
autoDownload: {
contacts: boolean
private: boolean
groups: boolean
channels: boolean
},
autoPlay: {
gifs: boolean,
videos: boolean
},
stickers: {
suggest: boolean,
loop: boolean
},
emoji: {
suggest: boolean,
big: boolean
},
background?: Background, // ! DEPRECATED
themes: Theme[],
theme: Theme['name'],
notifications: {
sound: boolean
},
nightTheme?: boolean, // ! DEPRECATED
timeFormat: 'h12' | 'h23'
},
keepSigned: boolean,
chatContextMenuHintWasShown: boolean,
stateId: number
};
export const STATE_INIT: State = {
allDialogsLoaded: {},
pinnedOrders: {},
contactsList: [],
updates: {},
filters: {},
maxSeenMsgId: 0,
stateCreatedTime: Date.now(),
recentEmoji: [],
topPeersCache: {},
recentSearch: [],
version: STATE_VERSION,
build: BUILD,
authState: {
_: IS_MOBILE ? 'authStateSignIn' : 'authStateSignQr'
},
hiddenPinnedMessages: {},
settings: {
messagesTextSize: 16,
sendShortcut: 'enter',
animationsEnabled: true,
autoDownload: {
contacts: true,
private: true,
groups: true,
channels: true
},
autoPlay: {
gifs: true,
videos: true
},
stickers: {
suggest: true,
loop: true
},
emoji: {
suggest: true,
big: true
},
themes: [{
name: 'day',
background: {
type: 'image',
blur: false,
slug: 'ByxGo2lrMFAIAAAAmkJxZabh8eM', // * new blurred camomile,
highlightningColor: 'hsla(85.5319, 36.9171%, 40.402%, 0.4)'
}
}, {
name: 'night',
background: {
type: 'color',
blur: false,
color: '#0f0f0f',
highlightningColor: 'hsla(0, 0%, 3.82353%, 0.4)'
}
}],
theme: 'system',
notifications: {
sound: false
},
timeFormat: new Date().toLocaleString().match(/\s(AM|PM)/) ? 'h12' : 'h23'
},
keepSigned: true,
chatContextMenuHintWasShown: false,
stateId: nextRandomUint(32)
};
const ALL_KEYS = Object.keys(STATE_INIT) as any as Array<keyof State>;
const REFRESH_KEYS = ['contactsList', 'stateCreatedTime',
'maxSeenMsgId', 'filters', 'topPeers'] as any as Array<keyof State>;
export type StatePeerType = 'recentSearch' | 'topPeer' | 'dialog' | 'contact' | 'topMessage' | 'self';
//const REFRESH_KEYS_WEEK = ['dialogs', 'allDialogsLoaded', 'updates', 'pinnedOrders'] as any as Array<keyof State>;
export class AppStateManager extends EventListenerBase<{
save: (state: State) => Promise<void>,
peerNeeded: (peerId: PeerId) => void,
peerUnneeded: (peerId: PeerId) => void
}> {
public static STATE_INIT = STATE_INIT;
private loaded: Promise<State>;
private log = logger('STATE'/* , LogLevels.error */);
private state: State;
private neededPeers: Map<PeerId, Set<string>> = new Map();
private singlePeerMap: Map<string, PeerId> = new Map();
public storages = {
users: new AppStorage<Record<UserId, User>, typeof DATABASE_STATE>(DATABASE_STATE, 'users'),
chats: new AppStorage<Record<ChatId, Chat>, typeof DATABASE_STATE>(DATABASE_STATE, 'chats'),
dialogs: new AppStorage<Record<PeerId, Dialog>, typeof DATABASE_STATE>(DATABASE_STATE, 'dialogs')
};
public storagesResults: {
users: User[],
chats: Chat[],
dialogs: Dialog[]
} = {} as any;
public storage = stateStorage;
public newVersion: string;
constructor() {
super();
this.loadSavedState();
rootScope.addEventListener('user_auth', () => {
this.requestPeerSingle(rootScope.myId, 'self');
});
}
public loadSavedState(): Promise<State> {
if(this.loaded) return this.loaded;
console.time('load state');
this.loaded = new Promise((resolve) => {
const storagesKeys = Object.keys(this.storages) as Array<keyof AppStateManager['storages']>;
const storagesPromises: Promise<any>[] = storagesKeys.map(key => this.storages[key].getAll());
const promises/* : Promise<any>[] */ = ALL_KEYS.map(key => stateStorage.get(key))
.concat(sessionStorage.get('user_auth'), sessionStorage.get('state_id'))
.concat(stateStorage.get('user_auth')) // support old webk format
.concat(storagesPromises);
Promise.all(promises).then(async(arr) => {
// await new Promise((resolve) => setTimeout(resolve, 3e3));
/* const self = this;
const skipHandleKeys = new Set(['isProxy', 'filters', 'drafts']);
const getHandler = (path?: string) => {
return {
get(target: any, key: any) {
if(key === 'isProxy') {
return true;
}
const prop = target[key];
if(prop !== undefined && !skipHandleKeys.has(key) && !prop.isProxy && typeof(prop) === 'object') {
target[key] = new Proxy(prop, getHandler(path || key));
return target[key];
}
return prop;
},
set(target: any, key: any, value: any) {
console.log('Setting', target, `.${key} to equal`, value, path);
target[key] = value;
// @ts-ignore
self.pushToState(path || key, path ? self.state[path] : value, false);
return true;
}
};
}; */
let state: State = this.state = {} as any;
// ! then can't store false values
for(let i = 0, length = ALL_KEYS.length; i < length; ++i) {
const key = ALL_KEYS[i];
const value = arr[i];
if(value !== undefined) {
// @ts-ignore
state[key] = value;
} else {
this.pushToState(key, copy(STATE_INIT[key]));
}
}
arr.splice(0, ALL_KEYS.length);
// * Read auth
let auth = arr.shift() as UserAuth | number;
const stateId = arr.shift() as number;
const shiftedWebKAuth = arr.shift() as UserAuth | number;
if(!auth && shiftedWebKAuth) { // support old webk auth
auth = shiftedWebKAuth;
const keys: string[] = ['dc', 'server_time_offset', 'xt_instance'];
for(let i = 1; i <= 5; ++i) {
keys.push(`dc${i}_server_salt`);
keys.push(`dc${i}_auth_key`);
}
const values = await Promise.all(keys.map(key => stateStorage.get(key as any)));
keys.push('user_auth');
values.push(typeof(auth) === 'number' || typeof(auth) === 'string' ? {dcID: values[0] || App.baseDcId, date: Date.now() / 1000 | 0, id: auth.toPeerId(false)} as UserAuth : auth);
let obj: any = {};
keys.forEach((key, idx) => {
obj[key] = values[idx];
});
await sessionStorage.set(obj);
}
/* if(!auth) { // try to read Webogram's session from localStorage
try {
const keys = Object.keys(localStorage);
for(let i = 0; i < keys.length; ++i) {
const key = keys[i];
let value: any;
try {
value = localStorage.getItem(key);
value = JSON.parse(value);
} catch(err) {
//console.error(err);
}
sessionStorage.set({
[key as any]: value
});
}
auth = sessionStorage.getFromCache('user_auth');
} catch(err) {
this.log.error('localStorage import error', err);
}
} */
if(auth) {
// ! Warning ! DON'T delete this
state.authState = {_: 'authStateSignedIn'};
rootScope.dispatchEvent('user_auth', typeof(auth) === 'number' || typeof(auth) === 'string' ?
{dcID: 0, date: Date.now() / 1000 | 0, id: auth.toPeerId(false)} :
auth); // * support old version
}
// * Read storages
for(let i = 0, length = storagesKeys.length; i < length; ++i) {
this.storagesResults[storagesKeys[i]] = arr[i] as any;
}
arr.splice(0, storagesKeys.length);
if(state.stateId !== stateId) {
if(stateId !== undefined) {
const preserve: Map<keyof State, State[keyof State]> = new Map([
['authState', undefined],
['stateId', undefined]
]);
preserve.forEach((_, key) => {
preserve.set(key, copy(state[key]));
});
state = this.state = copy(STATE_INIT);
preserve.forEach((value, key) => {
// @ts-ignore
state[key] = value;
});
for(const key in this.storagesResults) {
this.storagesResults[key as keyof AppStateManager['storagesResults']].length = 0;
}
this.storage.set(state);
}
await sessionStorage.set({
state_id: state.stateId
});
}
const time = Date.now();
if((state.stateCreatedTime + REFRESH_EVERY) < time) {
if(DEBUG) {
this.log('will refresh state', state.stateCreatedTime, time);
}
const r = (keys: typeof REFRESH_KEYS) => {
keys.forEach(key => {
this.pushToState(key, copy(STATE_INIT[key]));
// @ts-ignore
const s = this.storagesResults[key];
if(s && s.length) {
s.length = 0;
}
});
};
r(REFRESH_KEYS);
/* if((state.stateCreatedTime + REFRESH_EVERY_WEEK) < time) {
if(DEBUG) {
this.log('will refresh updates');
}
r(REFRESH_KEYS_WEEK);
} */
}
//state = this.state = new Proxy(state, getHandler());
// * support old version
if(!state.settings.hasOwnProperty('theme') && state.settings.hasOwnProperty('nightTheme')) {
state.settings.theme = state.settings.nightTheme ? 'night' : 'day';
this.pushToState('settings', state.settings);
}
// * support old version
if(!state.settings.hasOwnProperty('themes') && state.settings.background) {
state.settings.themes = copy(STATE_INIT.settings.themes);
const theme = state.settings.themes.find(t => t.name === state.settings.theme);
if(theme) {
theme.background = state.settings.background;
this.pushToState('settings', state.settings);
}
}
validateInitObject(STATE_INIT, state, (missingKey) => {
// @ts-ignore
this.pushToState(missingKey, state[missingKey]);
});
if(state.version !== STATE_VERSION || state.build !== BUILD/* || true */) {
// reset filters and dialogs if version is older
if(compareVersion(state.version, '0.8.7') === -1) {
this.state.allDialogsLoaded = copy(STATE_INIT.allDialogsLoaded);
this.state.filters = copy(STATE_INIT.filters);
const result = this.storagesResults.dialogs;
if(result?.length) {
result.length = 0;
}
}
if(compareVersion(state.version, STATE_VERSION) !== 0) {
this.newVersion = STATE_VERSION;
}
this.pushToState('version', STATE_VERSION);
this.pushToState('build', BUILD);
}
// ! probably there is better place for it
rootScope.settings = state.settings;
if(DEBUG) {
this.log('state res', state, copy(state));
}
//return resolve();
console.timeEnd('load state');
resolve(state);
}).catch(resolve);
});
return this.loaded;
}
public getState() {
return this.state === undefined ? this.loadSavedState() : Promise.resolve(this.state);
}
public setByKey(key: string, value: any) {
setDeepProperty(this.state, key, value);
rootScope.dispatchEvent('settings_updated', {key, value});
const first = key.split('.')[0];
// @ts-ignore
this.pushToState(first, this.state[first]);
}
public pushToState<T extends keyof State>(key: T, value: State[T], direct = true) {
if(direct) {
this.state[key] = value;
}
this.setKeyValueToStorage(key, value);
}
public setKeyValueToStorage<T extends keyof State>(key: T, value: State[T] = this.state[key]) {
this.storage.set({
[key]: value
});
}
public requestPeer(peerId: PeerId, type: StatePeerType, limit?: number) {
let set = this.neededPeers.get(peerId);
if(set && set.has(type)) {
return;
}
if(!set) {
set = new Set();
this.neededPeers.set(peerId, set);
}
set.add(type);
this.dispatchEvent('peerNeeded', peerId);
if(limit !== undefined) {
this.keepPeerSingle(peerId, type);
}
}
public requestPeerSingle(peerId: PeerId, type: StatePeerType, keepPeerIdSingle: PeerId = peerId) {
return this.requestPeer(peerId, type + '_' + keepPeerIdSingle as any, 1);
}
public releaseSinglePeer(peerId: PeerId, type: StatePeerType) {
return this.keepPeerSingle(NULL_PEER_ID, type + '_' + peerId as any);
}
public isPeerNeeded(peerId: PeerId) {
return this.neededPeers.has(peerId);
}
public keepPeerSingle(peerId: PeerId, type: StatePeerType) {
const existsPeerId = this.singlePeerMap.get(type);
if(existsPeerId && existsPeerId !== peerId && this.neededPeers.has(existsPeerId)) {
const set = this.neededPeers.get(existsPeerId);
set.delete(type);
if(!set.size) {
this.neededPeers.delete(existsPeerId);
this.dispatchEvent('peerUnneeded', existsPeerId);
}
}
if(peerId) {
this.singlePeerMap.set(type, peerId);
} else {
this.singlePeerMap.delete(type);
}
}
/* public resetState() {
for(let i in this.state) {
// @ts-ignore
this.state[i] = false;
}
sessionStorage.set(this.state).then(() => {
location.reload();
});
} */
}
//console.trace('appStateManager include');
const appStateManager = new AppStateManager();
MOUNT_CLASS_TO.appStateManager = appStateManager;
export default appStateManager;