/* * https://github.com/morethanwords/tweb * Copyright (C) 2019-2021 Eduard Kuzmenko * https://github.com/morethanwords/tweb/blob/master/LICENSE * * Originally from: * https://github.com/zhukov/webogram * Copyright (C) 2014 Igor Zhukov * https://github.com/zhukov/webogram/blob/master/LICENSE */ import { MOUNT_CLASS_TO } from "../../config/debug"; import { filterUnique } from "../../helpers/array"; import { CancellablePromise, deferredPromise } from "../../helpers/cancellablePromise"; import cleanSearchText from "../../helpers/cleanSearchText"; import cleanUsername from "../../helpers/cleanUsername"; import { tsNow } from "../../helpers/date"; import { formatPhoneNumber } from "../../helpers/formatPhoneNumber"; import { safeReplaceObject, isObject } from "../../helpers/object"; import { Chat, InputContact, InputMedia, InputUser, User as MTUser, UserProfilePhoto, UserStatus } from "../../layer"; import I18n, { i18n, LangPackKey } from "../langPack"; //import apiManager from '../mtproto/apiManager'; import apiManager from '../mtproto/mtprotoworker'; import { REPLIES_PEER_ID, SERVICE_PEER_ID } from "../mtproto/mtproto_config"; import serverTimeManager from "../mtproto/serverTimeManager"; import { RichTextProcessor } from "../richtextprocessor"; import rootScope from "../rootScope"; import SearchIndex from "../searchIndex"; import apiUpdatesManager from "./apiUpdatesManager"; import appChatsManager from "./appChatsManager"; import appPeersManager from "./appPeersManager"; import appStateManager from "./appStateManager"; export type User = MTUser.user; export type TopPeerType = 'correspondents' | 'bots_inline'; export type MyTopPeer = {id: number, rating: number}; export class AppUsersManager { private storage = appStateManager.storages.users; private users: {[userId: number]: User}; private usernames: {[username: string]: number}; private contactsIndex: SearchIndex; private contactsFillPromise: CancellablePromise>; private contactsList: Set; private updatedContactsList: boolean; private getTopPeersPromises: {[type in TopPeerType]?: Promise}; constructor() { this.clear(true); setInterval(this.updateUsersStatuses, 60000); rootScope.addEventListener('state_synchronized', this.updateUsersStatuses); rootScope.addMultipleEventsListeners({ updateUserStatus: (update) => { const userId = update.user_id; const user = this.users[userId]; if(user) { user.status = update.status; if(user.status) { if('expires' in user.status) { user.status.expires -= serverTimeManager.serverTimeOffset; } if('was_online' in user.status) { user.status.was_online -= serverTimeManager.serverTimeOffset; } } //user.sortStatus = this.getUserStatusForSort(user.status); rootScope.dispatchEvent('user_update', userId); this.setUserToStateIfNeeded(user); } //////else console.warn('No user by id:', userId); }, updateUserPhoto: (update) => { const userId = update.user_id; const user = this.users[userId]; if(user) { this.forceUserOnline(userId); if(update.photo._ === 'userProfilePhotoEmpty') { delete user.photo; } else { user.photo = safeReplaceObject(user.photo, update.photo); } this.setUserToStateIfNeeded(user); rootScope.dispatchEvent('user_update', userId); rootScope.dispatchEvent('avatar_update', userId); } else console.warn('No user by id:', userId); }, updateUserName: (update) => { const userId = update.user_id; const user = this.users[userId]; if(user) { this.forceUserOnline(userId); this.saveApiUser(Object.assign({}, user, { first_name: update.first_name, last_name: update.last_name, username: update.username }), true); } } }); /* case 'updateContactLink': this.onContactUpdated(update.user_id, update.my_link._ === 'contactLinkContact'); break; */ rootScope.addEventListener('language_change', (e) => { const userId = this.getSelf().id; this.contactsIndex.indexObject(userId, this.getUserSearchText(userId)); }); appStateManager.getState().then((state) => { const users = appStateManager.storagesResults.users; if(users.length) { for(let i = 0, length = users.length; i < length; ++i) { const user = users[i]; if(user) { this.users[user.id] = user; } } } const contactsList = state.contactsList; if(contactsList && Array.isArray(contactsList)) { contactsList.forEach(userId => { this.pushContact(userId); }); if(contactsList.length) { this.contactsFillPromise = deferredPromise(); this.contactsFillPromise.resolve(this.contactsList); } } appStateManager.addEventListener('peerNeeded', (peerId: number) => { if(peerId < 0 || this.storage.getFromCache(peerId)) { return; } this.storage.set({ [peerId]: this.getUser(peerId) }); }); appStateManager.addEventListener('peerUnneeded', (peerId: number) => { if(peerId < 0 || !this.storage.getFromCache(peerId)) { return; } this.storage.delete(peerId); }); }); } public clear(init = false) { if(!init) { const users = appStateManager.storagesResults.users; for(const _userId in this.users) { const userId = +_userId; if(!userId) continue; if(!appStateManager.isPeerNeeded(userId)) { const user = this.users[userId]; if(user.username) { delete this.usernames[cleanUsername(user.username)]; } users.findAndSplice((user) => user.id === userId); this.storage.delete(userId); delete this.users[userId]; } } } else { this.users = {}; this.usernames = {}; } this.getTopPeersPromises = {}; this.contactsIndex = this.createSearchIndex(); this.contactsFillPromise = undefined; this.contactsList = new Set(); this.updatedContactsList = false; } private onContactsModified() { const contactsList = [...this.contactsList]; appStateManager.pushToState('contactsList', contactsList); } public fillContacts() { if(this.contactsFillPromise && this.updatedContactsList) { return { cached: this.contactsFillPromise.isFulfilled, promise: this.contactsFillPromise }; } this.updatedContactsList = true; const promise = deferredPromise>(); apiManager.invokeApi('contacts.getContacts').then((result) => { if(result._ === 'contacts.contacts') { this.contactsList.clear(); this.saveApiUsers(result.users); result.contacts.forEach((contact) => { this.pushContact(contact.user_id); }); this.onContactsModified(); this.contactsFillPromise = promise; } promise.resolve(this.contactsList); }, () => { this.updatedContactsList = false; }); return { cached: this.contactsFillPromise?.isFulfilled, promise: this.contactsFillPromise || (this.contactsFillPromise = promise) }; } public resolveUsername(username: string): Promise { if(username[0] === '@') { username = username.slice(1); } username = username.toLowerCase(); if(this.usernames[username]) { return Promise.resolve(this.users[this.usernames[username]]); } return apiManager.invokeApi('contacts.resolveUsername', {username}).then(resolvedPeer => { this.saveApiUsers(resolvedPeer.users); appChatsManager.saveApiChats(resolvedPeer.chats); return appPeersManager.getPeer(appPeersManager.getPeerId(resolvedPeer.peer)); }); } public pushContact(userId: number) { this.contactsList.add(userId); this.contactsIndex.indexObject(userId, this.getUserSearchText(userId)); appStateManager.requestPeer(userId, 'contacts'); } public getUserSearchText(id: number) { const user = this.users[id]; if(!user) { return ''; } const arr: string[] = [ user.first_name, user.last_name, user.phone, user.username, user.pFlags.self ? I18n.format('SavedMessages', true) : '', user.pFlags.self ? 'Saved Messages' : '' ]; return arr.filter(Boolean).join(' '); } public getContacts(query?: string, includeSaved = false, sortBy: 'name' | 'online' | 'none' = 'name') { return this.fillContacts().promise.then(_contactsList => { let contactsList = [..._contactsList]; if(query) { const results = this.contactsIndex.search(query); const filteredContactsList = [...contactsList].filter(id => results.has(id)); contactsList = filteredContactsList; } if(sortBy === 'name') { contactsList.sort((userId1, userId2) => { const sortName1 = (this.users[userId1] || {}).sortName || ''; const sortName2 = (this.users[userId2] || {}).sortName || ''; return sortName1.localeCompare(sortName2); }); } else if(sortBy === 'online') { contactsList.sort((userId1, userId2) => { const status1 = appUsersManager.getUserStatusForSort(appUsersManager.getUser(userId1).status); const status2 = appUsersManager.getUserStatusForSort(appUsersManager.getUser(userId2).status); return status2 - status1; }); } contactsList.findAndSplice(p => p === rootScope.myId); if(includeSaved) { if(this.testSelfSearch(query)) { contactsList.unshift(rootScope.myId); } } return contactsList; }); } public toggleBlock(peerId: number, block: boolean) { return apiManager.invokeApiSingle(block ? 'contacts.block' : 'contacts.unblock', { id: appPeersManager.getInputPeerById(peerId) }).then(value => { if(value) { apiUpdatesManager.processLocalUpdate({ _: 'updatePeerBlocked', peer_id: appPeersManager.getOutputPeer(peerId), blocked: block }); } return value; }); } public testSelfSearch(query: string) { const user = this.getSelf(); const index = this.createSearchIndex(); index.indexObject(user.id, this.getUserSearchText(user.id)); return index.search(query).has(user.id); } private createSearchIndex() { return new SearchIndex({ clearBadChars: true, ignoreCase: true, latinize: true, includeTag: true }); } public saveApiUsers(apiUsers: MTUser[], override?: boolean) { apiUsers.forEach((user) => this.saveApiUser(user, override)); } public saveApiUser(user: MTUser, override?: boolean) { if(user._ === 'userEmpty') return; const userId = user.id; const oldUser = this.users[userId]; // ! commented block can affect performance ! // if(oldUser && !override) { // console.log('saveApiUser same'); // return; // } if(user.pFlags === undefined) { user.pFlags = {}; } if(user.pFlags.min && oldUser !== undefined) { return; } // * exclude from state // defineNotNumerableProperties(user, ['initials', 'num', 'rFirstName', 'rFullName', 'rPhone', 'sortName', 'sortStatus']); if(!oldUser || oldUser.username !== user.username) { if(oldUser?.username) { const oldSearchUsername = cleanUsername(oldUser.username); delete this.usernames[oldSearchUsername]; } if(user.username) { const searchUsername = cleanUsername(user.username); this.usernames[searchUsername] = userId; } } if(!oldUser || oldUser.initials === undefined || oldUser.sortName === undefined || oldUser.first_name !== user.first_name || oldUser.last_name !== user.last_name) { const fullName = user.first_name + (user.last_name ? ' ' + user.last_name : ''); user.sortName = user.pFlags.deleted ? '' : cleanSearchText(fullName, false); user.initials = RichTextProcessor.getAbbreviation(fullName); } else { user.sortName = oldUser.sortName; user.initials = oldUser.initials; } if(user.status) { if((user.status as UserStatus.userStatusOnline).expires) { (user.status as UserStatus.userStatusOnline).expires -= serverTimeManager.serverTimeOffset; } if((user.status as UserStatus.userStatusOffline).was_online) { (user.status as UserStatus.userStatusOffline).was_online -= serverTimeManager.serverTimeOffset; } } //user.sortStatus = user.pFlags.bot ? -1 : this.getUserStatusForSort(user.status); let changedPhoto = false, changedTitle = false; if(oldUser === undefined) { this.users[userId] = user; } else { if(user.first_name !== oldUser.first_name || user.last_name !== oldUser.last_name || user.username !== oldUser.username) { changedTitle = true; } const oldPhotoId = (oldUser.photo as UserProfilePhoto.userProfilePhoto)?.photo_id; const newPhotoId = (user.photo as UserProfilePhoto.userProfilePhoto)?.photo_id; if(oldPhotoId !== newPhotoId) { changedPhoto = true; } /* if(user.pFlags.bot && user.bot_info_version !== oldUser.bot_info_version) { } */ const wasContact = !!oldUser.pFlags.contact; const newContact = !!user.pFlags.contact; safeReplaceObject(oldUser, user); rootScope.dispatchEvent('user_update', userId); if(wasContact !== newContact) { this.onContactUpdated(userId, newContact, wasContact); } } if(changedPhoto) { rootScope.dispatchEvent('avatar_update', user.id); } if(changedTitle) { rootScope.dispatchEvent('peer_title_edit', user.id); } this.setUserToStateIfNeeded(user); } public setUserToStateIfNeeded(user: User) { if(appStateManager.isPeerNeeded(user.id)) { this.storage.set({ [user.id]: user }); } } public formatUserPhone(phone: string) { return '+' + formatPhoneNumber(phone).formatted; } public isUserOnlineVisible(id: number) { return this.getUserStatusForSort(id) > 3; } public getUserStatusForSort(status: User['status'] | number) { if(typeof(status) === 'number') { status = this.getUser(status).status; } if(status) { const expires = status._ === 'userStatusOnline' ? status.expires : (status._ === 'userStatusOffline' ? status.was_online : 0); if(expires) { return expires; } /* const timeNow = tsNow(true); switch(status._) { case 'userStatusRecently': return timeNow - 86400 * 3; case 'userStatusLastWeek': return timeNow - 86400 * 7; case 'userStatusLastMonth': return timeNow - 86400 * 30; } */ switch(status._) { case 'userStatusRecently': return 3; case 'userStatusLastWeek': return 2; case 'userStatusLastMonth': return 1; } } return 0; } public getUser(id: any): User { if(isObject(id)) { return id; } return this.users[id] || {id: id, pFlags: {deleted: true}, access_hash: ''} as User; } public getSelf() { return this.getUser(rootScope.myId); } public getUserStatusString(userId: number): HTMLElement { let key: LangPackKey; let args: any[]; switch(userId) { case REPLIES_PEER_ID: key = 'Peer.RepliesNotifications'; break; case SERVICE_PEER_ID: key = 'Peer.ServiceNotifications'; break; default: { if(this.isBot(userId)) { key = 'Bot'; break; } const user = this.getUser(userId); if(!user) { key = '' as any; break; } if(user.pFlags.support) { key = 'SupportStatus'; break; } switch(user.status?._) { case 'userStatusRecently': { key = 'Lately'; break; } case 'userStatusLastWeek': { key = 'WithinAWeek'; break; } case 'userStatusLastMonth': { key = 'WithinAMonth'; break; } case 'userStatusOffline': { const date = user.status.was_online; const now = Date.now() / 1000; if((now - date) < 60) { key = 'Peer.Status.justNow'; } else if((now - date) < 3600) { key = 'Peer.Status.minAgo'; const c = (now - date) / 60 | 0; args = [c]; } else if(now - date < 86400) { key = 'LastSeen.HoursAgo'; const c = (now - date) / 3600 | 0; args = [c]; } else { key = 'Peer.Status.LastSeenAt'; const d = new Date(date * 1000); args = [('0' + d.getDate()).slice(-2) + '.' + ('0' + (d.getMonth() + 1)).slice(-2), ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2)]; } break; } case 'userStatusOnline': { key = 'Online'; break; } default: { key = 'ALongTimeAgo'; break; } } break; } } return i18n(key, args); } public isBot(id: number) { return this.users[id] && this.users[id].pFlags.bot; } public isContact(id: number) { return this.contactsList.has(id) || (this.users[id] && this.users[id].pFlags.contact); } public isRegularUser(id: number) { const user = this.users[id]; return user && !this.isBot(id) && !user.pFlags.deleted && !user.pFlags.support; } public isNonContactUser(id: number) { return this.isRegularUser(id) && !this.isContact(id) && id !== rootScope.myId; } public hasUser(id: number, allowMin?: boolean) { const user = this.users[id]; return isObject(user) && (allowMin || !user.pFlags.min); } public canSendToUser(id: number) { const user = this.getUser(id); return !user.pFlags.deleted && user.id !== REPLIES_PEER_ID; } public getUserPhoto(id: number) { const user = this.getUser(id); return user && user.photo || { _: 'userProfilePhotoEmpty' }; } public getUserString(id: number) { const user = this.getUser(id); return 'u' + id + (user.access_hash ? '_' + user.access_hash : ''); } public getUserInput(id: number): InputUser { const user = this.getUser(id); if(user.pFlags && user.pFlags.self) { return {_: 'inputUserSelf'}; } return { _: 'inputUser', user_id: id, access_hash: user.access_hash }; } public getContactMediaInput(id: number): InputMedia.inputMediaContact { const user = this.getUser(id); return { _: 'inputMediaContact', first_name: user.first_name, last_name: user.last_name, phone_number: user.phone, vcard: '', user_id: id }; } public updateUsersStatuses = () => { const timestampNow = tsNow(true); for(const i in this.users) { const user = this.users[i]; this.updateUserStatus(user, timestampNow); } }; public updateUserStatus(user: MTUser.user, timestampNow = tsNow(true)) { if(user.status && user.status._ === 'userStatusOnline' && user.status.expires < timestampNow) { user.status = {_: 'userStatusOffline', was_online: user.status.expires}; rootScope.dispatchEvent('user_update', user.id); this.setUserToStateIfNeeded(user); } } public forceUserOnline(id: number, eventTimestamp?: number) { if(this.isBot(id)) { return; } const timestamp = tsNow(true); const onlineTimeFor = 60; if(eventTimestamp) { if((timestamp - eventTimestamp) >= onlineTimeFor) { return; } } else if(apiUpdatesManager.updatesState.syncLoading) { return; } const user = this.getUser(id); if(user && user.status && user.status._ !== 'userStatusOnline' && user.status._ !== 'userStatusEmpty' && !user.pFlags.support && !user.pFlags.deleted) { user.status = { _: 'userStatusOnline', expires: timestamp + onlineTimeFor }; //user.sortStatus = this.getUserStatusForSort(user.status); rootScope.dispatchEvent('user_update', id); this.setUserToStateIfNeeded(user); } } public importContact(first_name: string, last_name: string, phone: string) { return this.importContacts([{ first_name, last_name, phones: [phone] }]).then(userIds => { if(!userIds.length) { const error = new Error(); (error as any).type = 'NO_USER'; throw error; } return userIds[0]; }); } public importContacts(contacts: {phones: string[], first_name: string, last_name: string}[]) { const inputContacts: InputContact[] = []; for(let i = 0; i < contacts.length; ++i) { for(let j = 0; j < contacts[i].phones.length; ++j) { inputContacts.push({ _: 'inputPhoneContact', client_id: (i << 16 | j).toString(10), phone: contacts[i].phones[j], first_name: contacts[i].first_name, last_name: contacts[i].last_name }); } } return apiManager.invokeApi('contacts.importContacts', { contacts: inputContacts }).then((importedContactsResult) => { this.saveApiUsers(importedContactsResult.users); const userIds = importedContactsResult.imported.map((importedContact) => { this.onContactUpdated(importedContact.user_id, true); return importedContact.user_id; }); return userIds; }); } public getTopPeers(type: TopPeerType) { if(this.getTopPeersPromises[type]) return this.getTopPeersPromises[type]; return this.getTopPeersPromises[type] = appStateManager.getState().then((state) => { const cached = state.topPeersCache[type]; if(cached && (cached.cachedTime + 86400e3) > Date.now() && cached.peers) { return cached.peers; } return apiManager.invokeApi('contacts.getTopPeers', { [type]: true, offset: 0, limit: 15, hash: 0 }).then((result) => { let topPeers: MyTopPeer[] = []; if(result._ === 'contacts.topPeers') { //console.log(result); this.saveApiUsers(result.users); appChatsManager.saveApiChats(result.chats); if(result.categories.length) { topPeers = result.categories[0].peers.map((topPeer) => { const peerId = appPeersManager.getPeerId(topPeer.peer); appStateManager.requestPeer(peerId, 'topPeer'); return {id: peerId, rating: topPeer.rating}; }); } } state.topPeersCache[type] = { peers: topPeers, cachedTime: Date.now() }; appStateManager.pushToState('topPeersCache', state.topPeersCache); return topPeers; }); }); } public getBlocked(offset = 0, limit = 0) { return apiManager.invokeApiSingle('contacts.getBlocked', {offset, limit}).then(contactsBlocked => { this.saveApiUsers(contactsBlocked.users); appChatsManager.saveApiChats(contactsBlocked.chats); const count = contactsBlocked._ === 'contacts.blocked' ? contactsBlocked.users.length + contactsBlocked.chats.length : contactsBlocked.count; const peerIds = contactsBlocked.users.map(u => u.id).concat(contactsBlocked.chats.map(c => -c.id)); return {count, peerIds}; }); } /* public searchContacts(query: string, limit = 20) { return Promise.all([ this.getContacts(query), apiManager.invokeApi('contacts.search', { q: query, limit }) ]).then(results => { const [myContacts, peers] = results; this.saveApiUsers(peers.users); appChatsManager.saveApiChats(peers.chats); // * contacts.search returns duplicates in my_results const myResults = new Set(myContacts.concat(peers.my_results.map(p => appPeersManager.getPeerID(p)))); const out = { my_results: [...myResults].slice(0, limit), results: peers.results.map(p => appPeersManager.getPeerID(p)) }; return out; }); } */ public searchContacts(query: string, limit = 20) { return apiManager.invokeApiCacheable('contacts.search', { q: query, limit }, {cacheSeconds: 60}).then(peers => { this.saveApiUsers(peers.users); appChatsManager.saveApiChats(peers.chats); const out = { my_results: filterUnique(peers.my_results.map(p => appPeersManager.getPeerId(p))), // ! contacts.search returns duplicates in my_results results: peers.results.map(p => appPeersManager.getPeerId(p)) }; return out; }); } private onContactUpdated(userId: number, isContact: boolean, curIsContact = this.isContact(userId)) { if(isContact !== curIsContact) { if(isContact) { this.pushContact(userId); } else { this.contactsList.delete(userId); } this.onContactsModified(); rootScope.dispatchEvent('contacts_update', userId); } } public updateUsername(username: string) { return apiManager.invokeApi('account.updateUsername', { username }).then((user) => { this.saveApiUser(user); }); } public setUserStatus(userId: number, offline: boolean) { if(this.isBot(userId)) { return; } const user = this.users[userId]; if(user) { const status: any = offline ? { _: 'userStatusOffline', was_online: tsNow(true) } : { _: 'userStatusOnline', expires: tsNow(true) + 500 }; user.status = status; //user.sortStatus = this.getUserStatusForSort(user.status); rootScope.dispatchEvent('user_update', userId); } } public addContact(userId: number, first_name: string, last_name: string, phone: string, showPhone?: true) { /* if(!userId) { return this.importContacts([{ first_name, last_name, phones: [phone] }]); } */ return apiManager.invokeApi('contacts.addContact', { id: this.getUserInput(userId), first_name, last_name, phone, add_phone_privacy_exception: showPhone }).then((updates) => { apiUpdatesManager.processUpdateMessage(updates, {override: true}); this.onContactUpdated(userId, true); }); } public deleteContacts(userIds: number[]) { return apiManager.invokeApi('contacts.deleteContacts', { id: userIds.map(userId => this.getUserInput(userId)) }).then((updates) => { apiUpdatesManager.processUpdateMessage(updates, {override: true}); userIds.forEach(userId => { this.onContactUpdated(userId, false); }); }); } } const appUsersManager = new AppUsersManager(); MOUNT_CLASS_TO.appUsersManager = appUsersManager; export default appUsersManager