Remove groups table, conversation is single source of truth

This commit is contained in:
Scott Nonnenberg 2019-02-11 15:59:21 -08:00
parent b69eea543c
commit 5b54c9554e
16 changed files with 214 additions and 912 deletions

View File

@ -16,14 +16,6 @@ module.exports = {
removeDB,
removeIndexedDBFiles,
createOrUpdateGroup,
getGroupById,
getAllGroupIds,
getAllGroups,
bulkAddGroups,
removeGroupById,
removeAllGroups,
createOrUpdateIdentityKey,
getIdentityKeyById,
bulkAddIdentityKeys,
@ -625,6 +617,20 @@ async function updateToSchemaVersion10(currentVersion, instance) {
console.log('updateToSchemaVersion10: success!');
}
async function updateToSchemaVersion11(currentVersion, instance) {
if (currentVersion >= 11) {
return;
}
console.log('updateToSchemaVersion11: starting...');
await instance.run('BEGIN TRANSACTION;');
await instance.run('DROP TABLE groups;');
await instance.run('PRAGMA schema_version = 11;');
await instance.run('COMMIT TRANSACTION;');
console.log('updateToSchemaVersion11: success!');
}
const SCHEMA_VERSIONS = [
updateToSchemaVersion1,
updateToSchemaVersion2,
@ -636,6 +642,7 @@ const SCHEMA_VERSIONS = [
updateToSchemaVersion8,
updateToSchemaVersion9,
updateToSchemaVersion10,
updateToSchemaVersion11,
];
async function updateSchema(instance) {
@ -726,31 +733,6 @@ async function removeIndexedDBFiles() {
indexedDBPath = null;
}
const GROUPS_TABLE = 'groups';
async function createOrUpdateGroup(data) {
return createOrUpdate(GROUPS_TABLE, data);
}
async function getGroupById(id) {
return getById(GROUPS_TABLE, id);
}
async function getAllGroupIds() {
const rows = await db.all('SELECT id FROM groups ORDER BY id ASC;');
return map(rows, row => row.id);
}
async function getAllGroups() {
const rows = await db.all('SELECT json FROM groups ORDER BY id ASC;');
return map(rows, row => jsonToObject(row.json));
}
async function bulkAddGroups(array) {
return bulkAdd(GROUPS_TABLE, array);
}
async function removeGroupById(id) {
return removeById(GROUPS_TABLE, id);
}
async function removeAllGroups() {
return removeAllFromTable(GROUPS_TABLE);
}
const IDENTITY_KEYS_TABLE = 'identityKeys';
async function createOrUpdateIdentityKey(data) {
return createOrUpdate(IDENTITY_KEYS_TABLE, data);
@ -1701,7 +1683,6 @@ async function removeAll() {
promise = Promise.all([
db.run('BEGIN TRANSACTION;'),
db.run('DELETE FROM conversations;'),
db.run('DELETE FROM groups;'),
db.run('DELETE FROM identityKeys;'),
db.run('DELETE FROM items;'),
db.run('DELETE FROM messages;'),

View File

@ -615,7 +615,6 @@
<script type='text/javascript' src='js/views/conversation_list_item_view.js'></script>
<script type='text/javascript' src='js/views/conversation_list_view.js'></script>
<script type='text/javascript' src='js/views/contact_list_view.js'></script>
<script type='text/javascript' src='js/views/new_group_update_view.js'></script>
<script type='text/javascript' src='js/views/attachment_view.js'></script>
<script type='text/javascript' src='js/views/timestamp_view.js'></script>
<script type='text/javascript' src='js/views/message_view.js'></script>

View File

@ -210,14 +210,16 @@
sendTypingMessage(isTyping) {
const groupId = !this.isPrivate() ? this.id : null;
const recipientId = this.isPrivate() ? this.id : null;
const groupNumbers = this.getRecipients();
const sendOptions = this.getSendOptions();
this.wrapSend(
textsecure.messaging.sendTypingMessage(
{
groupId,
isTyping,
recipientId,
groupId,
groupNumbers,
},
sendOptions
)
@ -929,12 +931,36 @@
}
const conversationType = this.get('type');
const sendFunction = (() => {
const options = this.getSendOptions();
const groupNumbers = this.getRecipients();
const promise = (() => {
switch (conversationType) {
case Message.PRIVATE:
return textsecure.messaging.sendMessageToNumber;
return textsecure.messaging.sendMessageToNumber(
destination,
body,
attachmentsWithData,
quote,
preview,
now,
expireTimer,
profileKey,
options
);
case Message.GROUP:
return textsecure.messaging.sendMessageToGroup;
return textsecure.messaging.sendMessageToGroup(
destination,
groupNumbers,
body,
attachmentsWithData,
quote,
preview,
now,
expireTimer,
profileKey,
options
);
default:
throw new TypeError(
`Invalid conversation type: '${conversationType}'`
@ -942,22 +968,7 @@
}
})();
const options = this.getSendOptions();
return message.send(
this.wrapSend(
sendFunction(
destination,
body,
attachmentsWithData,
quote,
preview,
now,
expireTimer,
profileKey,
options
)
)
);
return message.send(this.wrapSend(promise));
});
},
@ -1239,25 +1250,31 @@
return message;
}
let sendFunc;
if (this.get('type') === 'private') {
sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber;
} else {
sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup;
}
let profileKey;
if (this.get('profileSharing')) {
profileKey = storage.get('profileKey');
}
const sendOptions = this.getSendOptions();
const promise = sendFunc(
this.get('id'),
this.get('expireTimer'),
message.get('sent_at'),
profileKey,
sendOptions
);
let promise;
if (this.get('type') === 'private') {
promise = textsecure.messaging.sendExpirationTimerUpdateToNumber(
this.get('id'),
this.get('expireTimer'),
message.get('sent_at'),
profileKey,
sendOptions
);
} else {
promise = textsecure.messaging.sendExpirationTimerUpdateToGroup(
this.get('id'),
this.getRecipients(),
this.get('expireTimer'),
message.get('sent_at'),
profileKey,
sendOptions
);
}
await message.send(this.wrapSend(promise));
@ -1335,6 +1352,7 @@
async leaveGroup() {
const now = Date.now();
if (this.get('type') === 'group') {
const groupNumbers = this.getRecipients();
this.set({ left: true });
await window.Signal.Data.updateConversation(this.id, this.attributes, {
Conversation: Whisper.Conversation,
@ -1355,7 +1373,9 @@
const options = this.getSendOptions();
message.send(
this.wrapSend(textsecure.messaging.leaveGroup(this.id, options))
this.wrapSend(
textsecure.messaging.leaveGroup(this.id, groupNumbers, options)
)
);
}
},

View File

@ -706,21 +706,35 @@
this.isReplayableError.bind(this)
);
// Remove the errors that aren't replayable
// Put the errors back which aren't replayable
this.set({ errors });
const profileKey = null;
let numbers = retries
const conversation = this.getConversation();
const intendedRecipients = this.get('recipients') || [];
const currentRecipients = conversation.getRecipients();
const profileKey = conversation.get('profileSharing')
? storage.get('profileKey')
: null;
const errorNumbers = retries
.map(retry => retry.number)
.filter(item => Boolean(item));
let numbers = _.intersection(
errorNumbers,
intendedRecipients,
currentRecipients
);
if (!numbers.length) {
window.log.warn(
'retrySend: No numbers in error set, using all recipients'
);
const conversation = this.getConversation();
if (conversation) {
numbers = conversation.getRecipients();
numbers = _.intersection(currentRecipients, intendedRecipients);
// We clear all errors here to start with a fresh slate, since we are starting
// from scratch on this message with a fresh set of recipients
this.set({ errors: null });
} else {
throw new Error(
@ -752,7 +766,6 @@
}
let promise;
const conversation = this.getConversation();
const options = conversation.getSendOptions();
if (conversation.isPrivate()) {

View File

@ -109,9 +109,9 @@ function createOutputStream(writer) {
};
}
async function exportContactAndGroupsToFile(parent) {
async function exportConversationListToFile(parent) {
const writer = await createFileAndWriter(parent, 'db.json');
return exportContactsAndGroups(writer);
return exportConversationList(writer);
}
function writeArray(stream, array) {
@ -137,7 +137,7 @@ function getPlainJS(collection) {
return collection.map(model => model.attributes);
}
async function exportContactsAndGroups(fileWriter) {
async function exportConversationList(fileWriter) {
const stream = createOutputStream(fileWriter);
stream.write('{');
@ -149,13 +149,6 @@ async function exportContactsAndGroups(fileWriter) {
window.log.info(`Exporting ${conversations.length} conversations`);
writeArray(stream, getPlainJS(conversations));
stream.write(',');
stream.write('"groups": ');
const groups = await window.Signal.Data.getAllGroups();
window.log.info(`Exporting ${groups.length} groups`);
writeArray(stream, groups);
stream.write('}');
await stream.close();
}
@ -167,7 +160,7 @@ async function importNonMessages(parent, options) {
}
function eliminateClientConfigInBackup(data, targetPath) {
const cleaned = _.pick(data, 'conversations', 'groups');
const cleaned = _.pick(data, 'conversations');
window.log.info('Writing configuration-free backup file back to disk');
try {
fs.writeFileSync(targetPath, JSON.stringify(cleaned));
@ -223,10 +216,8 @@ async function importFromJsonString(jsonString, targetPath, options) {
_.defaults(options, {
forceLightImport: false,
conversationLookup: {},
groupLookup: {},
});
const { groupLookup } = options;
const result = {
fullImport: true,
};
@ -251,7 +242,7 @@ async function importFromJsonString(jsonString, targetPath, options) {
// We mutate the on-disk backup to prevent the user from importing client
// configuration more than once - that causes lots of encryption errors.
// This of course preserves the true data: conversations and groups.
// This of course preserves the true data: conversations.
eliminateClientConfigInBackup(importObject, targetPath);
const storeNames = _.keys(importObject);
@ -262,12 +253,12 @@ async function importFromJsonString(jsonString, targetPath, options) {
const remainingStoreNames = _.without(
storeNames,
'conversations',
'unprocessed'
'unprocessed',
'groups' // in old data sets, but no longer included in database schema
);
await importConversationsFromJSON(conversations, options);
const SAVE_FUNCTIONS = {
groups: window.Signal.Data.createOrUpdateGroup,
identityKeys: window.Signal.Data.createOrUpdateIdentityKey,
items: window.Signal.Data.createOrUpdateItem,
preKeys: window.Signal.Data.createOrUpdatePreKey,
@ -292,29 +283,17 @@ async function importFromJsonString(jsonString, targetPath, options) {
return;
}
let skipCount = 0;
for (let i = 0, max = toImport.length; i < max; i += 1) {
const toAdd = unstringify(toImport[i]);
const haveGroupAlready =
storeName === 'groups' && groupLookup[getGroupKey(toAdd)];
if (haveGroupAlready) {
skipCount += 1;
} else {
// eslint-disable-next-line no-await-in-loop
await save(toAdd);
}
// eslint-disable-next-line no-await-in-loop
await save(toAdd);
}
window.log.info(
'Done importing to store',
storeName,
'Total count:',
toImport.length,
'Skipped:',
skipCount
toImport.length
);
})
);
@ -1160,14 +1139,6 @@ async function loadConversationLookup() {
return fromPairs(map(array, item => [getConversationKey(item), true]));
}
function getGroupKey(group) {
return group.id;
}
async function loadGroupsLookup() {
const array = await window.Signal.Data.getAllGroupIds();
return fromPairs(map(array, item => [getGroupKey(item), true]));
}
function getDirectoryForExport() {
return getDirectory();
}
@ -1254,7 +1225,7 @@ async function exportToDirectory(directory, options) {
const attachmentsDir = await createDirectory(directory, 'attachments');
await exportContactAndGroupsToFile(stagingDir);
await exportConversationListToFile(stagingDir);
await exportConversations(
Object.assign({}, options, {
messagesDir: stagingDir,
@ -1298,13 +1269,11 @@ async function importFromDirectory(directory, options) {
const lookups = await Promise.all([
loadMessagesLookup(),
loadConversationLookup(),
loadGroupsLookup(),
]);
const [messageLookup, conversationLookup, groupLookup] = lookups;
const [messageLookup, conversationLookup] = lookups;
options = Object.assign({}, options, {
messageLookup,
conversationLookup,
groupLookup,
});
const archivePath = path.join(directory, ARCHIVE_NAME);

View File

@ -47,14 +47,6 @@ module.exports = {
removeDB,
removeIndexedDBFiles,
createOrUpdateGroup,
getGroupById,
getAllGroupIds,
getAllGroups,
bulkAddGroups,
removeGroupById,
removeAllGroups,
createOrUpdateIdentityKey,
getIdentityKeyById,
bulkAddIdentityKeys,
@ -395,33 +387,6 @@ async function removeIndexedDBFiles() {
await channels.removeIndexedDBFiles();
}
// Groups
async function createOrUpdateGroup(data) {
await channels.createOrUpdateGroup(data);
}
async function getGroupById(id) {
const group = await channels.getGroupById(id);
return group;
}
async function getAllGroupIds() {
const ids = await channels.getAllGroupIds();
return ids;
}
async function getAllGroups() {
const groups = await channels.getAllGroups();
return groups;
}
async function bulkAddGroups(array) {
await channels.bulkAddGroups(array);
}
async function removeGroupById(id) {
await channels.removeGroupById(id);
}
async function removeAllGroups() {
await channels.removeAllGroups();
}
// Identity Keys
const IDENTITY_KEY_KEYS = ['publicKey'];

View File

@ -2,14 +2,12 @@
const { includes, isFunction, isString, last, map } = require('lodash');
const {
bulkAddGroups,
bulkAddSessions,
bulkAddIdentityKeys,
bulkAddPreKeys,
bulkAddSignedPreKeys,
bulkAddItems,
removeGroupById,
removeSessionById,
removeIdentityKeyById,
removePreKeyById,
@ -184,31 +182,6 @@ async function migrateToSQL({
complete = false;
lastIndex = null;
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const status = await migrateStoreToSQLite({
db,
// eslint-disable-next-line no-loop-func
save: bulkAddGroups,
remove: removeGroupById,
storeName: 'groups',
handleDOMException,
lastIndex,
batchSize: 10,
});
({ complete, lastIndex } = status);
}
window.log.info('migrateToSQL: migrate of groups complete');
try {
await clearStores(['groups']);
} catch (error) {
window.log.warn('Failed to clear groups store');
}
complete = false;
lastIndex = null;
while (!complete) {
// eslint-disable-next-line no-await-in-loop
const status = await migrateStoreToSQLite({

View File

@ -828,41 +828,6 @@
await textsecure.storage.protocol.removeAllSessions(number);
},
// Groups
async getGroup(groupId) {
if (groupId === null || groupId === undefined) {
throw new Error('Tried to get group for undefined/null id');
}
const group = await window.Signal.Data.getGroupById(groupId);
if (group) {
return group.data;
}
return undefined;
},
async putGroup(groupId, group) {
if (groupId === null || groupId === undefined) {
throw new Error('Tried to put group key for undefined/null id');
}
if (group === null || group === undefined) {
throw new Error('Tried to put undefined/null group object');
}
const data = {
id: groupId,
data: group,
};
await window.Signal.Data.createOrUpdateGroup(data);
},
async removeGroup(groupId) {
if (groupId === null || groupId === undefined) {
throw new Error('Tried to remove group key for undefined/null id');
}
await window.Signal.Data.removeGroupById(groupId);
},
// Not yet processed messages - for resiliency
getUnprocessedCount() {
return window.Signal.Data.getUnprocessedCount();

View File

@ -1135,16 +1135,7 @@
async showMembers(e, providedMembers, options = {}) {
_.defaults(options, { needVerify: false });
const fromConversation = this.model.isPrivate()
? [this.model.id]
: await textsecure.storage.groups.getNumbers(this.model.id);
const members =
providedMembers ||
fromConversation.map(id => ConversationController.get(id));
const model = this.model.getContactCollection();
model.reset(members);
const model = providedMembers || this.model.contactCollection;
const view = new Whisper.GroupMemberList({
model,
// we pass this in to allow nested panels

View File

@ -1,99 +0,0 @@
/* global Whisper, _ */
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
Whisper.NewGroupUpdateView = Whisper.View.extend({
tagName: 'div',
className: 'new-group-update',
templateName: 'new-group-update',
initialize(options) {
this.render();
this.avatarInput = new Whisper.FileInputView({
el: this.$('.group-avatar'),
window: options.window,
});
this.recipients_view = new Whisper.RecipientsInputView();
this.listenTo(this.recipients_view.typeahead, 'sync', () =>
this.model.contactCollection.models.forEach(model => {
if (this.recipients_view.typeahead.get(model)) {
this.recipients_view.typeahead.remove(model);
}
})
);
this.recipients_view.$el.insertBefore(this.$('.container'));
this.member_list_view = new Whisper.ContactListView({
collection: this.model.contactCollection,
className: 'members',
});
this.member_list_view.render();
this.$('.scrollable').append(this.member_list_view.el);
},
events: {
'click .back': 'goBack',
'click .send': 'send',
'focusin input.search': 'showResults',
'focusout input.search': 'hideResults',
},
hideResults() {
this.$('.results').hide();
},
showResults() {
this.$('.results').show();
},
goBack() {
this.trigger('back');
},
render_attributes() {
return {
name: this.model.getTitle(),
avatar: this.model.getAvatar(),
};
},
async send() {
// When we turn this view on again, need to handle avatars in the new way
// const avatarFile = await this.avatarInput.getThumbnail();
const now = Date.now();
const attrs = {
timestamp: now,
active_at: now,
name: this.$('.name').val(),
members: _.union(
this.model.get('members'),
this.recipients_view.recipients.pluck('id')
),
};
// if (avatarFile) {
// attrs.avatar = avatarFile;
// }
// Because we're no longer using Backbone-integrated saves, we need to manually
// clear the changed fields here so model.changed is accurate.
this.model.changed = {};
this.model.set(attrs);
const groupUpdate = this.model.changed;
await window.Signal.Data.updateConversation(
this.model.id,
this.model.attributes,
{ Conversation: Whisper.Conversation }
);
if (groupUpdate.avatar) {
this.model.trigger('change:avatar');
}
this.model.updateGroup(groupUpdate);
this.goBack();
},
});
})();

View File

@ -1,183 +0,0 @@
/* global Whisper, Backbone, ConversationController */
// eslint-disable-next-line func-names
(function() {
'use strict';
window.Whisper = window.Whisper || {};
const ContactsTypeahead = Backbone.TypeaheadCollection.extend({
typeaheadAttributes: [
'name',
'e164_number',
'national_number',
'international_number',
],
model: Whisper.Conversation,
async fetchContacts() {
const models = window.Signal.Data.getAllPrivateConversations({
ConversationCollection: Whisper.ConversationCollection,
});
this.reset(models);
},
});
Whisper.ContactPillView = Whisper.View.extend({
tagName: 'span',
className: 'recipient',
events: {
'click .remove': 'removeModel',
},
templateName: 'contact_pill',
initialize() {
const error = this.model.validate(this.model.attributes);
if (error) {
this.$el.addClass('error');
}
},
removeModel() {
this.$el.trigger('remove', { modelId: this.model.id });
this.remove();
},
render_attributes() {
return { name: this.model.getTitle() };
},
});
Whisper.RecipientListView = Whisper.ListView.extend({
itemView: Whisper.ContactPillView,
});
Whisper.SuggestionView = Whisper.ConversationListItemView.extend({
className: 'contact-details contact',
templateName: 'contact_name_and_number',
});
Whisper.SuggestionListView = Whisper.ConversationListView.extend({
itemView: Whisper.SuggestionView,
});
Whisper.RecipientsInputView = Whisper.View.extend({
className: 'recipients-input',
templateName: 'recipients-input',
initialize(options) {
if (options) {
this.placeholder = options.placeholder;
}
this.render();
this.$input = this.$('input.search');
this.$new_contact = this.$('.new-contact');
// Collection of recipients selected for the new message
this.recipients = new Whisper.ConversationCollection([], {
comparator: false,
});
// View to display the selected recipients
this.recipients_view = new Whisper.RecipientListView({
collection: this.recipients,
el: this.$('.recipients'),
});
// Collection of contacts to match user input against
this.typeahead = new ContactsTypeahead();
this.typeahead.fetchContacts();
// View to display the matched contacts from typeahead
this.typeahead_view = new Whisper.SuggestionListView({
collection: new Whisper.ConversationCollection([], {
comparator(m) {
return m.getTitle().toLowerCase();
},
}),
});
this.$('.contacts').append(this.typeahead_view.el);
this.initNewContact();
this.listenTo(this.typeahead, 'reset', this.filterContacts);
},
render_attributes() {
return { placeholder: this.placeholder || 'name or phone number' };
},
events: {
'input input.search': 'filterContacts',
'select .new-contact': 'addNewRecipient',
'select .contacts': 'addRecipient',
'remove .recipient': 'removeRecipient',
},
filterContacts() {
const query = this.$input.val();
if (query.length) {
if (this.maybeNumber(query)) {
this.new_contact_view.model.set('id', query);
this.new_contact_view.render().$el.show();
} else {
this.new_contact_view.$el.hide();
}
this.typeahead_view.collection.reset(this.typeahead.typeahead(query));
} else {
this.resetTypeahead();
}
},
initNewContact() {
if (this.new_contact_view) {
this.new_contact_view.undelegateEvents();
this.new_contact_view.$el.hide();
}
// Creates a view to display a new contact
this.new_contact_view = new Whisper.ConversationListItemView({
el: this.$new_contact,
model: ConversationController.create({
type: 'private',
newContact: true,
}),
}).render();
},
addNewRecipient() {
this.recipients.add(this.new_contact_view.model);
this.initNewContact();
this.resetTypeahead();
},
addRecipient(e, conversation) {
this.recipients.add(this.typeahead.remove(conversation.id));
this.resetTypeahead();
},
removeRecipient(e, data) {
const model = this.recipients.remove(data.modelId);
if (!model.get('newContact')) {
this.typeahead.add(model);
}
this.filterContacts();
},
reset() {
this.delegateEvents();
this.typeahead_view.delegateEvents();
this.recipients_view.delegateEvents();
this.new_contact_view.delegateEvents();
this.typeahead.add(
this.recipients.filter(model => !model.get('newContact'))
);
this.recipients.reset([]);
this.resetTypeahead();
this.typeahead.fetchContacts();
},
resetTypeahead() {
this.new_contact_view.$el.hide();
this.$input.val('').focus();
this.typeahead_view.collection.reset([]);
},
maybeNumber(number) {
return number.match(/^\+?[0-9]*$/);
},
});
})();

View File

@ -229,6 +229,8 @@ MessageReceiver.prototype.extend({
const job = () => appJobPromise;
this.appPromise = promise.then(job, job);
return Promise.resolve();
},
onclose(ev) {
window.log.info(
@ -868,7 +870,7 @@ MessageReceiver.prototype.extend({
p = this.handleEndSession(destination);
}
return p.then(() =>
this.processDecrypted(envelope, msg, this.number).then(message => {
this.processDecrypted(envelope, msg).then(message => {
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const isMe = envelope.source === textsecure.storage.user.getNumber();
@ -910,7 +912,7 @@ MessageReceiver.prototype.extend({
p = this.handleEndSession(envelope.source);
}
return p.then(() =>
this.processDecrypted(envelope, msg, envelope.source).then(message => {
this.processDecrypted(envelope, msg).then(message => {
const groupId = message.group && message.group.id;
const isBlocked = this.isGroupBlocked(groupId);
const isMe = envelope.source === textsecure.storage.user.getNumber();
@ -1168,39 +1170,13 @@ MessageReceiver.prototype.extend({
let groupDetails = groupBuffer.next();
const promises = [];
while (groupDetails !== undefined) {
const getGroupDetails = details => {
// eslint-disable-next-line no-param-reassign
details.id = details.id.toBinary();
if (details.active) {
return textsecure.storage.groups
.getGroup(details.id)
.then(existingGroup => {
if (existingGroup === undefined) {
return textsecure.storage.groups.createNewGroup(
details.members,
details.id
);
}
return textsecure.storage.groups.updateNumbers(
details.id,
details.members
);
})
.then(() => details);
}
return Promise.resolve(details);
};
const promise = getGroupDetails(groupDetails)
.then(details => {
const ev = new Event('group');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.groupDetails = details;
return this.dispatchAndWait(ev);
})
.catch(e => {
window.log.error('error processing group', e);
});
groupDetails.id = groupDetails.id.toBinary();
const ev = new Event('group');
ev.confirm = this.removeFromCache.bind(this, envelope);
ev.groupDetails = groupDetails;
const promise = this.dispatchAndWait(ev).catch(e => {
window.log.error('error processing group', e);
});
groupDetails = groupBuffer.next();
promises.push(promise);
}
@ -1275,7 +1251,7 @@ MessageReceiver.prototype.extend({
})
);
},
processDecrypted(envelope, decrypted, source) {
processDecrypted(envelope, decrypted) {
/* eslint-disable no-bitwise, no-param-reassign */
const FLAGS = textsecure.protobuf.DataMessage.Flags;
@ -1311,63 +1287,24 @@ MessageReceiver.prototype.extend({
if (decrypted.group !== null) {
decrypted.group.id = decrypted.group.id.toBinary();
const storageGroups = textsecure.storage.groups;
promises.push(
storageGroups.getNumbers(decrypted.group.id).then(existingGroup => {
if (existingGroup === undefined) {
if (
decrypted.group.type !==
textsecure.protobuf.GroupContext.Type.UPDATE
) {
decrypted.group.members = [source];
window.log.warn('Got message for unknown group');
}
return textsecure.storage.groups.createNewGroup(
decrypted.group.members,
decrypted.group.id
);
}
const fromIndex = existingGroup.indexOf(source);
if (fromIndex < 0) {
// TODO: This could be indication of a race...
window.log.warn(
'Sender was not a member of the group they were sending from'
);
}
switch (decrypted.group.type) {
case textsecure.protobuf.GroupContext.Type.UPDATE:
decrypted.body = null;
decrypted.attachments = [];
return textsecure.storage.groups.updateNumbers(
decrypted.group.id,
decrypted.group.members
);
case textsecure.protobuf.GroupContext.Type.QUIT:
decrypted.body = null;
decrypted.attachments = [];
if (source === this.number) {
return textsecure.storage.groups.deleteGroup(
decrypted.group.id
);
}
return textsecure.storage.groups.removeNumber(
decrypted.group.id,
source
);
case textsecure.protobuf.GroupContext.Type.DELIVER:
decrypted.group.name = null;
decrypted.group.members = [];
decrypted.group.avatar = null;
return Promise.resolve();
default:
this.removeFromCache(envelope);
throw new Error('Unknown group message type');
}
})
);
switch (decrypted.group.type) {
case textsecure.protobuf.GroupContext.Type.UPDATE:
decrypted.body = null;
decrypted.attachments = [];
break;
case textsecure.protobuf.GroupContext.Type.QUIT:
decrypted.body = null;
decrypted.attachments = [];
break;
case textsecure.protobuf.GroupContext.Type.DELIVER:
decrypted.group.name = null;
decrypted.group.members = [];
decrypted.group.avatar = null;
break;
default:
this.removeFromCache(envelope);
throw new Error('Unknown group message type');
}
}
const attachmentCount = decrypted.attachments.length;

View File

@ -560,7 +560,7 @@ MessageSender.prototype = {
async sendTypingMessage(options = {}, sendOptions = {}) {
const ACTION_ENUM = textsecure.protobuf.TypingMessage.Action;
const { recipientId, groupId, isTyping, timestamp } = options;
const { recipientId, groupId, groupNumbers, isTyping, timestamp } = options;
// We don't want to send typing messages to our other devices, but we will
// in the group case.
@ -574,7 +574,7 @@ MessageSender.prototype = {
}
const recipients = groupId
? _.without(await textsecure.storage.groups.getNumbers(groupId), myNumber)
? _.without(groupNumbers, myNumber)
: [recipientId];
const groupIdBuffer = groupId
? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(groupId)
@ -882,6 +882,7 @@ MessageSender.prototype = {
sendMessageToGroup(
groupId,
groupNumbers,
messageText,
attachments,
quote,
@ -891,59 +892,50 @@ MessageSender.prototype = {
profileKey,
options
) {
return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => {
if (targetNumbers === undefined) {
return Promise.reject(new Error('Unknown Group'));
}
const me = textsecure.storage.user.getNumber();
const numbers = groupNumbers.filter(number => number !== me);
if (numbers.length === 0) {
return Promise.reject(new Error('No other members in the group'));
}
const me = textsecure.storage.user.getNumber();
const numbers = targetNumbers.filter(number => number !== me);
if (numbers.length === 0) {
return Promise.reject(new Error('No other members in the group'));
}
return this.sendMessage(
{
recipients: numbers,
body: messageText,
timestamp,
attachments,
quote,
preview,
needsSync: true,
expireTimer,
profileKey,
group: {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
return this.sendMessage(
{
recipients: numbers,
body: messageText,
timestamp,
attachments,
quote,
preview,
needsSync: true,
expireTimer,
profileKey,
group: {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
options
);
});
},
options
);
},
createGroup(targetNumbers, name, avatar, options) {
createGroup(targetNumbers, id, name, avatar, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(id);
return textsecure.storage.groups
.createNewGroup(targetNumbers)
.then(group => {
proto.group.id = stringToArrayBuffer(group.id);
const { numbers } = group;
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.members = targetNumbers;
proto.group.name = name;
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.members = numbers;
proto.group.name = name;
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(numbers, proto, Date.now(), options).then(
() => proto.group.id
);
});
});
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(
targetNumbers,
proto,
Date.now(),
options
).then(() => proto.group.id);
});
},
updateGroup(groupId, name, avatar, targetNumbers, options) {
@ -953,121 +945,87 @@ MessageSender.prototype = {
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.name = name;
proto.group.members = targetNumbers;
return textsecure.storage.groups
.addNumbers(groupId, targetNumbers)
.then(numbers => {
if (numbers === undefined) {
return Promise.reject(new Error('Unknown Group'));
}
proto.group.members = numbers;
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(numbers, proto, Date.now(), options).then(
() => proto.group.id
);
});
});
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(
targetNumbers,
proto,
Date.now(),
options
).then(() => proto.group.id);
});
},
addNumberToGroup(groupId, number, options) {
addNumberToGroup(groupId, newNumbers, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
return textsecure.storage.groups
.addNumbers(groupId, [number])
.then(numbers => {
if (numbers === undefined)
return Promise.reject(new Error('Unknown Group'));
proto.group.members = numbers;
return this.sendGroupProto(numbers, proto, Date.now(), options);
});
proto.group.members = newNumbers;
return this.sendGroupProto(newNumbers, proto, Date.now(), options);
},
setGroupName(groupId, name, options) {
setGroupName(groupId, name, groupNumbers, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.name = name;
proto.group.members = groupNumbers;
return textsecure.storage.groups.getNumbers(groupId).then(numbers => {
if (numbers === undefined)
return Promise.reject(new Error('Unknown Group'));
proto.group.members = numbers;
return this.sendGroupProto(numbers, proto, Date.now(), options);
});
return this.sendGroupProto(groupNumbers, proto, Date.now(), options);
},
setGroupAvatar(groupId, avatar, options) {
setGroupAvatar(groupId, avatar, groupNumbers, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.members = groupNumbers;
return textsecure.storage.groups.getNumbers(groupId).then(numbers => {
if (numbers === undefined)
return Promise.reject(new Error('Unknown Group'));
proto.group.members = numbers;
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(numbers, proto, Date.now(), options);
});
return this.makeAttachmentPointer(avatar).then(attachment => {
proto.group.avatar = attachment;
return this.sendGroupProto(groupNumbers, proto, Date.now(), options);
});
},
leaveGroup(groupId, options) {
leaveGroup(groupId, groupNumbers, options) {
const proto = new textsecure.protobuf.DataMessage();
proto.group = new textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = textsecure.protobuf.GroupContext.Type.QUIT;
return textsecure.storage.groups.getNumbers(groupId).then(numbers => {
if (numbers === undefined)
return Promise.reject(new Error('Unknown Group'));
return textsecure.storage.groups
.deleteGroup(groupId)
.then(() => this.sendGroupProto(numbers, proto, Date.now(), options));
});
return this.sendGroupProto(groupNumbers, proto, Date.now(), options);
},
sendExpirationTimerUpdateToGroup(
groupId,
groupNumbers,
expireTimer,
timestamp,
profileKey,
options
) {
return textsecure.storage.groups.getNumbers(groupId).then(targetNumbers => {
if (targetNumbers === undefined)
return Promise.reject(new Error('Unknown Group'));
const me = textsecure.storage.user.getNumber();
const numbers = targetNumbers.filter(number => number !== me);
if (numbers.length === 0) {
return Promise.reject(new Error('No other members in the group'));
}
return this.sendMessage(
{
recipients: numbers,
timestamp,
needsSync: true,
expireTimer,
profileKey,
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
group: {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
const me = textsecure.storage.user.getNumber();
const numbers = groupNumbers.filter(number => number !== me);
if (numbers.length === 0) {
return Promise.reject(new Error('No other members in the group'));
}
return this.sendMessage(
{
recipients: numbers,
timestamp,
needsSync: true,
expireTimer,
profileKey,
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
group: {
id: groupId,
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
options
);
});
},
options
);
},
sendExpirationTimerUpdateToNumber(
number,

View File

@ -1,160 +0,0 @@
/* global window, getString, libsignal, textsecure */
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
(function() {
/** *******************
*** Group Storage ***
******************** */
window.textsecure = window.textsecure || {};
window.textsecure.storage = window.textsecure.storage || {};
// create a random group id that we haven't seen before.
function generateNewGroupId() {
const groupId = getString(libsignal.crypto.getRandomBytes(16));
return textsecure.storage.protocol.getGroup(groupId).then(group => {
if (group === undefined) {
return groupId;
}
window.log.warn('group id collision'); // probably a bad sign.
return generateNewGroupId();
});
}
window.textsecure.storage.groups = {
createNewGroup(numbers, groupId) {
return new Promise(resolve => {
if (groupId !== undefined) {
resolve(
textsecure.storage.protocol.getGroup(groupId).then(group => {
if (group !== undefined) {
throw new Error('Tried to recreate group');
}
})
);
} else {
resolve(
generateNewGroupId().then(newGroupId => {
// eslint-disable-next-line no-param-reassign
groupId = newGroupId;
})
);
}
}).then(() => {
const me = textsecure.storage.user.getNumber();
let haveMe = false;
const finalNumbers = [];
// eslint-disable-next-line no-restricted-syntax, guard-for-in
for (const i in numbers) {
const number = numbers[i];
if (!textsecure.utils.isNumberSane(number))
throw new Error('Invalid number in group');
if (number === me) haveMe = true;
if (finalNumbers.indexOf(number) < 0) finalNumbers.push(number);
}
if (!haveMe) finalNumbers.push(me);
const groupObject = {
numbers: finalNumbers,
numberRegistrationIds: {},
};
// eslint-disable-next-line no-restricted-syntax, guard-for-in
for (const i in finalNumbers) {
groupObject.numberRegistrationIds[finalNumbers[i]] = {};
}
return textsecure.storage.protocol
.putGroup(groupId, groupObject)
.then(() => ({ id: groupId, numbers: finalNumbers }));
});
},
getNumbers(groupId) {
return textsecure.storage.protocol.getGroup(groupId).then(group => {
if (!group) {
return undefined;
}
return group.numbers;
});
},
removeNumber(groupId, number) {
return textsecure.storage.protocol.getGroup(groupId).then(group => {
if (group === undefined) return undefined;
const me = textsecure.storage.user.getNumber();
if (number === me)
throw new Error(
'Cannot remove ourselves from a group, leave the group instead'
);
const i = group.numbers.indexOf(number);
if (i > -1) {
group.numbers.splice(i, 1);
// eslint-disable-next-line no-param-reassign
delete group.numberRegistrationIds[number];
return textsecure.storage.protocol
.putGroup(groupId, group)
.then(() => group.numbers);
}
return group.numbers;
});
},
addNumbers(groupId, numbers) {
return textsecure.storage.protocol.getGroup(groupId).then(group => {
if (group === undefined) return undefined;
// eslint-disable-next-line no-restricted-syntax, guard-for-in
for (const i in numbers) {
const number = numbers[i];
if (!textsecure.utils.isNumberSane(number))
throw new Error('Invalid number in set to add to group');
if (group.numbers.indexOf(number) < 0) {
group.numbers.push(number);
// eslint-disable-next-line no-param-reassign
group.numberRegistrationIds[number] = {};
}
}
return textsecure.storage.protocol
.putGroup(groupId, group)
.then(() => group.numbers);
});
},
deleteGroup(groupId) {
return textsecure.storage.protocol.removeGroup(groupId);
},
getGroup(groupId) {
return textsecure.storage.protocol.getGroup(groupId).then(group => {
if (group === undefined) return undefined;
return { id: groupId, numbers: group.numbers };
});
},
updateNumbers(groupId, numbers) {
return textsecure.storage.protocol.getGroup(groupId).then(group => {
if (group === undefined)
throw new Error('Tried to update numbers for unknown group');
if (
numbers.filter(textsecure.utils.isNumberSane).length < numbers.length
)
throw new Error('Invalid number in new group members');
const added = numbers.filter(
number => group.numbers.indexOf(number) < 0
);
return textsecure.storage.groups.addNumbers(groupId, added);
});
},
};
})();

View File

@ -362,8 +362,6 @@
<script type='text/javascript' src='../js/views/conversation_list_item_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/conversation_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/contact_list_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/new_group_update_view.js' data-cover></script>
<script type="text/javascript" src="../js/views/group_update_view.js"></script>
<script type='text/javascript' src='../js/views/attachment_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/timestamp_view.js' data-cover></script>
<script type='text/javascript' src='../js/views/message_view.js' data-cover></script>
@ -386,7 +384,6 @@
<script type="text/javascript" src="metadata/SecretSessionCipher_test.js"></script>
<script type="text/javascript" src="views/whisper_view_test.js"></script>
<script type="text/javascript" src="views/group_update_view_test.js"></script>
<script type="text/javascript" src="views/attachment_view_test.js"></script>
<script type="text/javascript" src="views/timestamp_view_test.js"></script>
<script type="text/javascript" src="views/list_view_test.js"></script>

View File

@ -1,24 +0,0 @@
/* global Whisper */
describe('GroupUpdateView', () => {
it('should show new group members', () => {
const view = new Whisper.GroupUpdateView({
model: { joined: ['Alice', 'Bob'] },
}).render();
assert.match(view.$el.text(), /Alice.*Bob.*joined the group/);
});
it('should note updates to the title', () => {
const view = new Whisper.GroupUpdateView({
model: { name: 'New name' },
}).render();
assert.match(view.$el.text(), /Title is now 'New name'/);
});
it('should say "Updated the group"', () => {
const view = new Whisper.GroupUpdateView({
model: { avatar: 'New avatar' },
}).render();
assert.match(view.$el.text(), /Updated the group/);
});
});