session-desktop/ts/session/conversations/ConversationController.ts

328 lines
10 KiB
TypeScript

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 { deleteAllMessagesByConvoIdNoConfirmation } 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<any>;
/**
* 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({
id: conversation.id,
data: conversation.getConversationModelProps(),
})
);
}
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<ConversationModel> {
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 deleteAllMessagesByConvoIdNoConfirmation(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,
isApproved: false,
});
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.conversationChanged({
id: conversation.id,
data: conversation.getConversationModelProps(),
})
);
window.inboxStore?.dispatch(conversationActions.conversationRemoved(conversation.id));
}
window.log.info(`deleteContact !isPrivate, convo removed from store: ${id}`);
}
}
public getConversations(): Array<ConversationModel> {
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([]);
}
}