/**
* This file is part of Threema Web.
*
* Threema Web is free software: you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or (at
* your option) any later version.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
* General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Threema Web. If not, see .
*/
import {ReceiverService} from '../services/receiver';
/**
* A simple HashSet implementation based on a JavaScript object.
*
* Only strings can be stored in this set.
*/
class StringHashSet {
private setObj = {};
private val = {};
public add(str: string): void {
this.setObj[str] = this.val;
}
public contains(str: string): boolean {
return this.setObj[str] === this.val;
}
public remove(str: string): void {
delete this.setObj[str];
}
public values() {
let values = [];
for (let i in this.setObj) {
if (this.setObj[i] === this.val) {
values.push(i);
}
}
return values;
}
}
angular.module('3ema.container', [])
.factory('Container', ['$filter', '$log', 'ReceiverService',
function($filter, $log, receiverService: ReceiverService) {
type ContactMap = Map;
type GroupMap = Map;
type DistributionListMap = Map;
type MessageMap = Map>;
type ConversationFilter = (data: threema.Conversation[]) => threema.Conversation[];
type ConversationConverter = (data: threema.Conversation) => threema.Conversation;
type MessageConverter = (data: threema.Message) => threema.Message;
/**
* Collection that manages receivers like contacts, groups or distribution lists.
*
* Think of it like the "address book".
*/
class Receivers implements threema.Container.Receivers {
public me: threema.MeReceiver = null;
public contacts: ContactMap = new Map();
public groups: GroupMap = new Map();
public distributionLists: DistributionListMap = new Map();
/**
* Get the receiver map for the specified type.
*/
public get(receiverType: threema.ReceiverType): threema.Receiver | Map {
switch (receiverType) {
case 'me':
return this.me;
case 'contact':
return this.contacts;
case 'group':
return this.groups;
case 'distributionList':
return this.distributionLists;
default:
throw new Error('Unknown or invalid receiver type: ' + receiverType);
}
}
/**
* Get the receiver matching a certain template.
*/
public getData(receiver: threema.BaseReceiver): threema.Receiver | null {
if (receiver.type === 'me') {
return this.me.id === receiver.id ? this.me : undefined;
} else {
const receivers = this.get(receiver.type) as Map;
return receivers.get(receiver.id);
}
}
/**
* Set receiver data.
*/
public set(data: threema.Container.ReceiverData) {
this.setMe(data['me' as threema.ReceiverType]);
this.setContacts(data['contact' as threema.ReceiverType]);
this.setGroups(data['group' as threema.ReceiverType]);
this.setDistributionLists(data['distributionList' as threema.ReceiverType]);
}
/**
* Set own contact.
*/
public setMe(data: threema.MeReceiver): void {
data.type = 'me';
this.me = data;
}
/**
* Set contacts.
*/
public setContacts(data: threema.ContactReceiver[]): void {
this.contacts = new Map(data.map((c) => {
c.type = 'contact';
return [c.id, c];
}) as any) as ContactMap;
if (this.me !== undefined) {
this.contacts.set(this.me.id, this.me);
}
}
/**
* Set groups.
*/
public setGroups(data: threema.GroupReceiver[]): void {
this.groups = new Map(data.map((g) => {
g.type = 'group';
return [g.id, g];
}) as any) as GroupMap;
}
/**
* Set distribution lists.
*/
public setDistributionLists(data: threema.DistributionListReceiver[]): void {
this.distributionLists = new Map(data.map((d) => {
d.type = 'distributionList';
return [d.id, d];
}) as any) as DistributionListMap;
}
public extend(receiverType: threema.ReceiverType, data: threema.Receiver): threema.Receiver {
switch (receiverType) {
case 'me':
return this.extendMe(data as threema.MeReceiver);
case 'contact':
return this.extendContact(data as threema.ContactReceiver);
case 'group':
return this.extendGroup(data as threema.GroupReceiver);
case 'distributionList':
return this.extendDistributionList(data as threema.DistributionListReceiver);
default:
throw new Error('Unknown or invalid receiver type: ' + receiverType);
}
}
public extendDistributionList(data: threema.DistributionListReceiver): threema.DistributionListReceiver {
let distributionListReceiver = this.distributionLists.get(data.id);
if (distributionListReceiver === undefined) {
data.type = 'distributionList';
this.distributionLists.set(data.id, data);
return data;
}
// update existing object
distributionListReceiver = angular.extend(distributionListReceiver, data);
return distributionListReceiver;
}
public extendGroup(data: threema.GroupReceiver): threema.GroupReceiver {
let groupReceiver = this.groups.get(data.id);
if (groupReceiver === undefined) {
data.type = 'group';
this.groups.set(data.id, data);
return data;
}
// update existing object
groupReceiver = angular.extend(groupReceiver, data);
return groupReceiver;
}
public extendMe(data: threema.MeReceiver): threema.MeReceiver {
if (this.me === undefined) {
data.type = 'me';
this.me = data;
return data;
}
// update existing object
this.me = angular.extend(this.me, data);
return this.me;
}
public extendContact(data: threema.ContactReceiver): threema.ContactReceiver {
let contactReceiver = this.contacts.get(data.id);
if (contactReceiver === undefined) {
data.type = 'contact';
this.contacts.set(data.id, data);
return data;
}
// update existing object
contactReceiver = angular.extend(contactReceiver, data);
return contactReceiver;
}
}
class Conversations implements threema.Container.Conversations {
private conversations: threema.Conversation[] = [];
public filter: ConversationFilter = null;
private converter: ConversationConverter = null;
/**
* Get conversations.
*/
public get(): threema.Conversation[] {
let conversations = this.conversations;
if (this.filter != null) {
conversations = this.filter(conversations);
}
if (this.converter != null) {
conversations = conversations.map(this.converter);
}
return conversations;
}
/**
* Set conversations.
*/
public set(data: threema.Conversation[]): void {
data.forEach((existingConversation: threema.Conversation) => {
this.updateOrAdd(existingConversation);
});
}
public add(conversation: threema.Conversation): void {
this.conversations.splice(conversation.position, 0, conversation);
}
public updateOrAdd(conversation: threema.Conversation): void {
let moveDirection = 0;
let updated = false;
for (let p of this.conversations.keys()) {
if (receiverService.compare(this.conversations[p], conversation)) {
// ok, replace me and break
let old = this.conversations[p];
if (old.position !== conversation.position) {
// position also changed...
moveDirection = old.position > conversation.position ? -1 : 1;
}
this.conversations[p] = conversation;
updated = true;
}
}
// reset the position field to correct the sorting
if (moveDirection !== 0) {
// reindex
let before = true;
for (let p in this.conversations) {
if (receiverService.compare(this.conversations[p], conversation)) {
before = false;
} else if (before && moveDirection < 0) {
this.conversations[p].position++;
} else if (!before && moveDirection > 0) {
this.conversations[p].position++;
}
}
// sort by position field
this.conversations.sort(function (convA, convB) {
return convA.position - convB.position;
});
} else if (!updated) {
this.add(conversation);
}
}
public remove(conversation: threema.Conversation): void {
for (let p of this.conversations.keys()) {
if (receiverService.compare(this.conversations[p], conversation)) {
// remove conversation from array
this.conversations.splice(p, 1);
return;
}
}
}
/**
* Set a filter.
*/
public setFilter(filter: ConversationFilter): void {
this.filter = filter;
}
/**
* Set a converter.
*/
public setConverter(converter: ConversationConverter): void {
this.converter = converter;
}
}
/**
* Messages between local user and a receiver.
*/
class ReceiverMessages {
// The message id used as reference when paging.
public referenceMsgId: number = null;
// Whether a message request has been sent yet.
public requested = false;
// This flag indicates that more (older) messages are available.
public more = true;
// List of messages.
public list: threema.Message[] = [];
}
/**
* This class manages all messages for the current user.
*/
class Messages implements threema.Container.Messages {
// The messages are stored in date-ascending order,
// newest messages are appended, older messages are prepended.
private messages: MessageMap = new Map();
// Message converter
public converter: MessageConverter = null;
/**
* Ensure that the receiver exists in the receiver map.
*/
private lazyCreate(receiver: threema.Receiver): void {
// If the type is not yet known, create a new type map.
if (!this.messages.has(receiver.type)) {
this.messages.set(receiver.type, new Map());
}
// If the receiver is not yet known, initialize it.
const typeMap = this.messages.get(receiver.type);
if (!typeMap.has(receiver.id)) {
typeMap.set(receiver.id, new ReceiverMessages());
}
}
/**
* Return the `ReceiverMessages` instance for the specified receiver.
*
* If the receiver is not known yet, it is initialized.
*/
private getReceiverMessages(receiver: threema.Receiver): ReceiverMessages {
this.lazyCreate(receiver);
return this.messages.get(receiver.type).get(receiver.id);
}
/**
* Return the list of messages for the specified receiver.
*
* If the receiver is not known yet, it is initialized with an empty
* message list.
*/
public getList(receiver: threema.Receiver): threema.Message[] {
return this.getReceiverMessages(receiver).list;
}
/**
* Clear and reset all loaded messages but do not remove objects
* @param $scope
*/
public clear($scope: ng.IScope): void {
this.messages.forEach ((messageMap: Map,
receiverType: threema.ReceiverType) => {
messageMap.forEach ((messages: ReceiverMessages, id: string) => {
messages.requested = false;
messages.referenceMsgId = null;
messages.more = true;
messages.list = [];
this.notify({
id: id,
type: receiverType,
} as threema.Receiver, $scope);
});
});
}
/**
* Reset the cached messages of a receiver (e.g. the receiver was locked by the mobile)
*/
public clearReceiverMessages(receiver: threema.Receiver): Number {
let cachedMessageCount = 0;
if (this.messages.has(receiver.type)) {
let typeMessages = this.messages.get(receiver.type);
if (typeMessages.has(receiver.id)) {
cachedMessageCount = typeMessages.get(receiver.id).list.length;
typeMessages.delete(receiver.id);
}
}
return cachedMessageCount;
}
/**
* Return whether messages from/for the specified receiver are available.
*/
public contains(receiver: threema.Receiver): boolean {
return this.messages.has(receiver.type) &&
this.messages.get(receiver.type).has(receiver.id);
}
/**
* Return whether there are more (older) messages available to fetch
* for the specified receiver.
*/
public hasMore(receiver: threema.Receiver): boolean {
return this.getReceiverMessages(receiver).more;
}
/**
* Set the "more" flag for the specified receiver.
*
* The flag indicates that more (older) messages are available.
*/
public setMore(receiver: threema.Receiver, more: boolean): void {
this.getReceiverMessages(receiver).more = more;
}
/**
* Return the reference msg id for the specified receiver.
*/
public getReferenceMsgId(receiver: threema.Receiver): number {
return this.getReceiverMessages(receiver).referenceMsgId;
}
/**
* Return whether the messages for the specified receiver are already
* requested.
*/
public isRequested(receiver: threema.Receiver): boolean {
return this.getReceiverMessages(receiver).requested;
}
/**
* Set the requested flag for the specified receiver.
*/
public setRequested(receiver: threema.Receiver): void {
const messages = this.getReceiverMessages(receiver);
// If a request was already pending, this must be a bug.
if (messages.requested) {
throw new Error('Message request for receiver ' + receiver.id + ' still pending');
}
// Set requested
messages.requested = true;
}
/**
* Clear the "requested" flag for the specified receiver messages.
*/
public clearRequested(receiver): void {
const messages = this.getReceiverMessages(receiver);
messages.requested = false;
}
/**
* Append newer messages.
*
* Messages must be sorted ascending by date.
*/
public addNewer(receiver: threema.Receiver, messages: threema.Message[]): void {
if (messages.length === 0) {
// do nothing
return;
}
const receiverMessages = this.getReceiverMessages(receiver);
// if the list is empty, add the current message as ref
if (receiverMessages.list.length === 0) {
receiverMessages.referenceMsgId = messages[0].id;
}
receiverMessages.list.push.apply(receiverMessages.list, messages);
}
/**
* Prepend older messages.
*
* Messages must be sorted ascending by date (oldest first).
*/
public addOlder(receiver: threema.Receiver, messages: threema.Message[]): void {
if (messages.length === 0) {
// do nothing
return;
}
// Get reference to message list for the specified receiver
const receiverMessages = this.getReceiverMessages(receiver);
// If the first or last message is already contained in the list,
// do nothing.
const firstId = messages[0].id;
const lastId = messages[messages.length - 1].id;
const predicate = (msg: threema.Message) => msg.id === firstId || msg.id === lastId;
if (receiverMessages.list.findIndex(predicate, receiverMessages.list) !== -1) {
$log.warn('Messages to be prepended intersect with existing messages:', messages);
return;
}
// Add the oldest message as ref
receiverMessages.referenceMsgId = messages[0].id;
receiverMessages.list.unshift.apply(receiverMessages.list, messages);
}
/**
* Update/replace a message with a newer version.
*
* Return a boolean indicating whether the message was found and
* replaced, or not.
*/
public update(receiver: threema.Receiver, message: threema.Message): boolean {
const list = this.getList(receiver);
for (let i = 0; i < list.length; i++) {
if (list[i].id === message.id) {
if (message.thumbnail === undefined) {
// do not reset the thumbnail
message.thumbnail = list[i].thumbnail;
}
list[i] = message;
return true;
}
}
return false;
}
/**
* Update a thumbnail of a message, if a message was found the method will return true
*
* @param receiver
* @param messageId
* @param thumbnailImage
* @returns {boolean}
*/
public setThumbnail(receiver: threema.Receiver, messageId: number, thumbnailImage: string): boolean {
const list = this.getList(receiver);
for (let message of list) {
if (message.id === messageId) {
if (message.thumbnail === undefined) {
message.thumbnail = {img: thumbnailImage} as threema.Thumbnail;
} else {
message.thumbnail.img = thumbnailImage;
}
return true;
}
}
return false;
}
/**
* Remove a message.
*
* Return a boolean indicating whether the message was found and
* removed, or not.
*/
public remove(receiver: threema.Receiver, messageId: number): boolean {
const list = this.getList(receiver);
for (let i = 0; i < list.length; i++) {
if (list[i].id === messageId) {
list.splice(i, 1);
return true;
}
}
return false;
}
/**
* Remove a message.
*
* Return a boolean indicating whether the message was found and
* removed, or not.
*/
public removeTemporary(receiver: threema.Receiver, temporaryMessageId: string): boolean {
const list = this.getList(receiver);
for (let i = 0; i < list.length; i++) {
if (list[i].temporaryId === temporaryMessageId) {
list.splice(i, 1);
return true;
}
}
return false;
}
public bindTemporaryToMessageId(receiver: threema.Receiver, temporaryId: string, messageId: number): boolean {
const list = this.getList(receiver);
for (let item of list) {
if (item.temporaryId === temporaryId) {
if (item.id !== undefined) {
// do not bind to a new message id
return false;
}
// reset temporary id
item.temporaryId = null;
// assign to "real" message id
item.id = messageId;
return true;
}
}
return false;
}
public notify(receiver: threema.Receiver, $scope: ng.IScope) {
$scope.$broadcast('threema.receiver.' + receiver.type + '.' + receiver.id + '.messages',
this.getList(receiver), this.hasMore(receiver));
}
/**
* register a message change notify on the given scope
* return the CURRENT list of loaded messages
*/
public register(receiver: threema.Receiver, $scope: ng.IScope, callback: any): threema.Message[] {
$scope.$on('threema.receiver.' + receiver.type + '.' + receiver.id + '.messages', callback);
return this.getList(receiver);
}
public updateFirstUnreadMessage(receiver: threema.Receiver): void {
const receiverMessages = this.getReceiverMessages(receiver);
if (receiverMessages !== undefined && receiverMessages.list.length > 0) {
// remove unread
let removedElements = 0;
let firstUnreadMessageIndex;
receiverMessages.list = receiverMessages.list.filter((message: threema.Message, index: number) => {
if (message.type === 'status'
&& message.statusType === 'firstUnreadMessage') {
removedElements++;
return false;
} else if (firstUnreadMessageIndex === undefined
&& !message.isOutbox
&& message.unread) {
firstUnreadMessageIndex = index;
}
return true;
});
if (firstUnreadMessageIndex !== undefined) {
firstUnreadMessageIndex -= removedElements;
receiverMessages.list.splice(firstUnreadMessageIndex, 0 , {
type: 'status',
isStatus: true,
statusType: 'firstUnreadMessage',
} as threema.Message);
}
}
}
}
/**
* Converters transform a message or conversation.
*/
class Converter {
public static unicodeToEmoji(message: threema.Message) {
if (message.type === 'text') {
message.body = emojione.toShort(message.body);
}
return message;
}
/**
* Retrieve the receiver corresponding to this conversation and set the
* `receiver` attribute.
*/
public static addReceiverToConversation(receivers: Receivers) {
return (conversation: threema.Conversation): threema.Conversation => {
conversation.receiver = receivers.getData({
type: conversation.type,
id: conversation.id,
} as threema.Receiver);
return conversation;
};
}
}
class Filters {
public static hasData(receivers) {
return (obj) => $filter('hasData')(obj, receivers);
}
public static hasContact(contacts) {
return (obj) => $filter('hasContact')(obj, contacts);
}
public static isValidMessage(contacts) {
return (obj) => $filter('isValidMessage')(obj, contacts);
}
}
/**
* This class manages the typing flags for receivers.
*
* Internally values are stored in a hash set for efficient lookup.
*/
class Typing implements threema.Container.Typing {
private set = new StringHashSet();
private getReceiverUid(receiver: threema.ContactReceiver): string {
return receiver.type + '-' + receiver.id;
}
public setTyping(receiver: threema.ContactReceiver): void {
this.set.add(this.getReceiverUid(receiver));
}
public unsetTyping(receiver: threema.ContactReceiver): void {
this.set.remove(this.getReceiverUid(receiver));
}
public isTyping(receiver: threema.ContactReceiver): boolean {
return this.set.contains(this.getReceiverUid(receiver));
}
}
/**
* Holds message drafts and quotes
*/
class Drafts implements threema.Container.Drafts {
private quotes = new Map();
// Use to implement draft texts!
private texts = new Map();
private getReceiverUid(receiver: threema.Receiver): string {
// do not use receiver.type => can be null
return receiver.id;
}
public setQuote(receiver: threema.Receiver, quote: threema.Quote): void {
this.quotes.set(this.getReceiverUid(receiver), quote);
}
public removeQuote(receiver: threema.Receiver): void {
this.quotes.delete(this.getReceiverUid(receiver));
}
public getQuote(receiver: threema.Receiver): threema.Quote {
return this.quotes.get(this.getReceiverUid(receiver));
}
public setText(receiver: threema.Receiver, draftMessage: string): void {
this.texts.set(this.getReceiverUid(receiver), draftMessage);
}
public removeText(receiver: threema.Receiver): void {
this.texts.delete(this.getReceiverUid(receiver));
}
public getText(receiver: threema.Receiver): string {
return this.texts.get(this.getReceiverUid(receiver));
}
}
return {
Converter: Converter as threema.Container.Converter,
Filters: Filters as threema.Container.Filters,
createReceivers: () => new Receivers(),
createConversations: () => new Conversations(),
createMessages: () => new Messages(),
createTyping: () => new Typing(),
createDrafts: () => new Drafts(),
} as threema.Container.Factory;
}]);