import { getAllConversations, getAllGroupsInvolvingId, removeConversation, saveConversation, } from '../../data/data'; import { ConversationAttributes, ConversationCollection, ConversationModel, ConversationTypeEnum, } from '../../models/conversation'; import { BlockedNumberController } from '../../util'; import { getSwarmFor } from '../snode_api/snodePool'; import { PubKey } from '../types'; import { actions as conversationActions } from '../../state/ducks/conversations'; import { getV2OpenGroupRoom, removeV2OpenGroupRoom } from '../../data/opengroups'; import _ from 'lodash'; import { getOpenGroupManager } from '../../opengroup/opengroupV2/OpenGroupManagerV2'; import { deleteAuthToken, DeleteAuthTokenRequest } from '../../opengroup/opengroupV2/ApiAuth'; import { deleteMessagesByConvoIdNoConfirmation } from '../../interactions/conversationInteractions'; let instance: ConversationController | null; export const getConversationController = () => { if (instance) { return instance; } instance = new ConversationController(); return instance; }; export class ConversationController { private readonly conversations: ConversationCollection; private _initialFetchComplete: boolean = false; private _initialPromise?: Promise; /** * Do not call this constructor. You get the ConversationController through getConversationController() only */ constructor() { this.conversations = new ConversationCollection(); } // FIXME this could return | undefined public get(id: string): ConversationModel { if (!this._initialFetchComplete) { throw new Error('getConversationController().get() needs complete initial fetch'); } return this.conversations.get(id); } public getOrThrow(id: string): ConversationModel { if (!this._initialFetchComplete) { throw new Error('getConversationController().get() needs complete initial fetch'); } const convo = this.conversations.get(id); if (convo) { return convo; } throw new Error(`Conversation ${id} does not exist on getConversationController().get()`); } // Needed for some model setup which happens during the initial fetch() call below public getUnsafe(id: string): ConversationModel | undefined { return this.conversations.get(id); } public dangerouslyCreateAndAdd(attributes: ConversationAttributes) { return this.conversations.add(attributes); } public getOrCreate(id: string, type: ConversationTypeEnum) { if (typeof id !== 'string') { throw new TypeError("'id' must be a string"); } if (type !== ConversationTypeEnum.PRIVATE && type !== ConversationTypeEnum.GROUP) { throw new TypeError(`'type' must be 'private' or 'group' got: '${type}'`); } if (!this._initialFetchComplete) { throw new Error('getConversationController().get() needs complete initial fetch'); } let conversation = this.conversations.get(id); if (conversation) { return conversation; } conversation = this.conversations.add({ id, type, version: 2, } as any); const create = async () => { try { await saveConversation(conversation.attributes); } catch (error) { window?.log?.error( 'Conversation save failed! ', id, type, 'Error:', error && error.stack ? error.stack : error ); throw error; } return conversation; }; conversation.initialPromise = create(); conversation.initialPromise.then(async () => { if (window?.inboxStore) { window.inboxStore?.dispatch( conversationActions.conversationAdded(conversation.id, conversation.getProps()) ); } if (!conversation.isPublic()) { await Promise.all([ conversation.updateProfileAvatar(), // NOTE: we request snodes updating the cache, but ignore the result void getSwarmFor(id), ]); } }); return conversation; } public getContactProfileNameOrShortenedPubKey(pubKey: string): string { const conversation = getConversationController().get(pubKey); if (!conversation) { return pubKey; } return conversation.getContactProfileNameOrShortenedPubKey(); } public getContactProfileNameOrFullPubKey(pubKey: string): string { const conversation = this.conversations.get(pubKey); if (!conversation) { return pubKey; } return conversation.getContactProfileNameOrFullPubKey(); } public isMediumGroup(hexEncodedGroupPublicKey: string): boolean { const convo = this.conversations.get(hexEncodedGroupPublicKey); if (convo) { return !!convo.isMediumGroup(); } return false; } public async getOrCreateAndWait( id: string | PubKey, type: ConversationTypeEnum ): Promise { const initialPromise = this._initialPromise !== undefined ? this._initialPromise : Promise.resolve(); return initialPromise.then(() => { if (!id) { return Promise.reject(new Error('getOrCreateAndWait: invalid id passed.')); } const pubkey = id && (id as any).key ? (id as any).key : id; const conversation = this.getOrCreate(pubkey, type); if (conversation) { return conversation.initialPromise.then(() => conversation); } return Promise.reject(new Error('getOrCreateAndWait: did not get conversation')); }); } public async getAllGroupsInvolvingId(id: string) { const groups = await getAllGroupsInvolvingId(id); return groups.map((group: any) => this.conversations.add(group)); } public async deleteContact(id: string) { if (!this._initialFetchComplete) { throw new Error('getConversationController().get() needs complete initial fetch'); } window.log.info(`deleteContact with ${id}`); const conversation = this.conversations.get(id); if (!conversation) { window.log.warn(`deleteContact no such convo ${id}`); return; } // Closed/Medium group leaving if (conversation.isClosedGroup()) { window.log.info(`deleteContact ClosedGroup case: ${id}`); await conversation.leaveClosedGroup(); // open group v2 } else if (conversation.isOpenGroupV2()) { window?.log?.info('leaving open group v2', conversation.id); const roomInfos = await getV2OpenGroupRoom(conversation.id); if (roomInfos) { if (roomInfos.token) { // leave the group on the remote server await deleteAuthToken( _.pick(roomInfos, 'serverUrl', 'roomId', 'token') as DeleteAuthTokenRequest ); } getOpenGroupManager().removeRoomFromPolledRooms(roomInfos); // remove the roomInfos locally for this open group room try { await removeV2OpenGroupRoom(conversation.id); } catch (e) { window?.log?.info('removeV2OpenGroupRoom failed:', e); } } } // those are the stuff to do for all contact types window.log.info(`deleteContact destroyingMessages: ${id}`); await deleteMessagesByConvoIdNoConfirmation(conversation.id); window.log.info(`deleteContact message destroyed: ${id}`); // if this conversation is a private conversation it's in fact a `contact` for desktop. // we just want to remove everything related to it, set the active_at to undefined // so conversation still exists (useful for medium groups members or opengroups) but is not shown on the UI if (conversation.isPrivate()) { window.log.info(`deleteContact isPrivate, marking as inactive: ${id}`); conversation.set('active_at', undefined); await conversation.commit(); } else { window.log.info(`deleteContact !isPrivate, removing convo from DB: ${id}`); await removeConversation(id); window.log.info(`deleteContact !isPrivate, convo removed from DB: ${id}`); this.conversations.remove(conversation); if (window?.inboxStore) { window.inboxStore?.dispatch(conversationActions.conversationRemoved(conversation.id)); window.inboxStore?.dispatch( conversationActions.conversationChanged(conversation.id, conversation.getProps()) ); } window.log.info(`deleteContact !isPrivate, convo removed from store: ${id}`); } } public getConversations(): Array { return Array.from(this.conversations.models); } public unsafeDelete(convo: ConversationModel) { this.conversations.remove(convo); } public async load() { window?.log?.info('ConversationController: starting initial fetch'); if (this.conversations.length) { throw new Error('ConversationController: Already loaded!'); } const load = async () => { try { const collection = await getAllConversations(); this.conversations.add(collection.models); this._initialFetchComplete = true; const promises: any = []; this.conversations.forEach((conversation: ConversationModel) => { if (!conversation.get('lastMessage')) { // tslint:disable-next-line: no-void-expression promises.push(conversation.updateLastMessage()); } promises.concat([conversation.updateProfileName(), conversation.updateProfileAvatar()]); }); await Promise.all(promises); // Remove any unused images window.profileImages.removeImagesNotInArray(this.conversations.map((c: any) => c.id)); window?.log?.info('ConversationController: done with initial fetch'); } catch (error) { window?.log?.error( 'ConversationController: initial fetch failed', error && error.stack ? error.stack : error ); throw error; } }; await BlockedNumberController.load(); this._initialPromise = load(); return this._initialPromise; } public loadPromise() { return this._initialPromise; } public reset() { this._initialPromise = Promise.resolve(); this._initialFetchComplete = false; if (window?.inboxStore) { window.inboxStore?.dispatch(conversationActions.removeAllConversations()); } this.conversations.reset([]); } }