GroupsV2: Better group invite behavior
This commit is contained in:
parent
b9ff4f07d3
commit
d51a0b5ece
24 changed files with 1408 additions and 313 deletions
392
ts/groups.ts
392
ts/groups.ts
|
@ -20,6 +20,7 @@ import {
|
|||
MessageAttributesType,
|
||||
} from './model-types.d';
|
||||
import {
|
||||
createProfileKeyCredentialPresentation,
|
||||
decryptGroupBlob,
|
||||
decryptProfileKey,
|
||||
decryptProfileKeyCredentialPresentation,
|
||||
|
@ -28,9 +29,11 @@ import {
|
|||
deriveGroupPublicParams,
|
||||
deriveGroupSecretParams,
|
||||
encryptGroupBlob,
|
||||
encryptUuid,
|
||||
getAuthCredentialPresentation,
|
||||
getClientZkAuthOperations,
|
||||
getClientZkGroupCipher,
|
||||
getClientZkProfileOperations,
|
||||
} from './util/zkgroup';
|
||||
import {
|
||||
arrayBufferToBase64,
|
||||
|
@ -51,6 +54,9 @@ import { GroupCredentialsType } from './textsecure/WebAPI';
|
|||
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message';
|
||||
import { ConversationModel } from './models/conversations';
|
||||
|
||||
export type GroupV2AccessCreateChangeType = {
|
||||
type: 'create';
|
||||
};
|
||||
export type GroupV2AccessAttributesChangeType = {
|
||||
type: 'access-attributes';
|
||||
newPrivilege: number;
|
||||
|
@ -112,6 +118,7 @@ export type GroupV2PendingRemoveManyChangeType = {
|
|||
};
|
||||
|
||||
export type GroupV2ChangeDetailType =
|
||||
| GroupV2AccessCreateChangeType
|
||||
| GroupV2TitleChangeType
|
||||
| GroupV2AvatarChangeType
|
||||
| GroupV2AccessAttributesChangeType
|
||||
|
@ -156,7 +163,7 @@ export const MASTER_KEY_LENGTH = 32;
|
|||
const TEMPORAL_AUTH_REJECTED_CODE = 401;
|
||||
const GROUP_ACCESS_DENIED_CODE = 403;
|
||||
|
||||
// Group Changes
|
||||
// Group Modifications
|
||||
|
||||
export function buildDisappearingMessagesTimerChange({
|
||||
expireTimer,
|
||||
|
@ -189,6 +196,91 @@ export function buildDisappearingMessagesTimerChange({
|
|||
return actions;
|
||||
}
|
||||
|
||||
export function buildDeletePendingMemberChange({
|
||||
uuid,
|
||||
group,
|
||||
}: {
|
||||
uuid: string;
|
||||
group: ConversationAttributesType;
|
||||
}): GroupChangeClass.Actions {
|
||||
const actions = new window.textsecure.protobuf.GroupChange.Actions();
|
||||
|
||||
if (!group.secretParams) {
|
||||
throw new Error(
|
||||
'buildDeletePendingMemberChange: group was missing secretParams!'
|
||||
);
|
||||
}
|
||||
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
|
||||
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
|
||||
|
||||
const deletePendingMember = new window.textsecure.protobuf.GroupChange.Actions.DeletePendingMemberAction();
|
||||
deletePendingMember.deletedUserId = uuidCipherTextBuffer;
|
||||
|
||||
actions.version = (group.revision || 0) + 1;
|
||||
actions.deletePendingMembers = [deletePendingMember];
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function buildDeleteMemberChange({
|
||||
uuid,
|
||||
group,
|
||||
}: {
|
||||
uuid: string;
|
||||
group: ConversationAttributesType;
|
||||
}): GroupChangeClass.Actions {
|
||||
const actions = new window.textsecure.protobuf.GroupChange.Actions();
|
||||
|
||||
if (!group.secretParams) {
|
||||
throw new Error('buildDeleteMemberChange: group was missing secretParams!');
|
||||
}
|
||||
const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
|
||||
const uuidCipherTextBuffer = encryptUuid(clientZkGroupCipher, uuid);
|
||||
|
||||
const deleteMember = new window.textsecure.protobuf.GroupChange.Actions.DeleteMemberAction();
|
||||
deleteMember.deletedUserId = uuidCipherTextBuffer;
|
||||
|
||||
actions.version = (group.revision || 0) + 1;
|
||||
actions.deleteMembers = [deleteMember];
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export function buildPromoteMemberChange({
|
||||
group,
|
||||
profileKeyCredentialBase64,
|
||||
serverPublicParamsBase64,
|
||||
}: {
|
||||
group: ConversationAttributesType;
|
||||
profileKeyCredentialBase64: string;
|
||||
serverPublicParamsBase64: string;
|
||||
}): GroupChangeClass.Actions {
|
||||
const actions = new window.textsecure.protobuf.GroupChange.Actions();
|
||||
|
||||
if (!group.secretParams) {
|
||||
throw new Error(
|
||||
'buildDisappearingMessagesTimerChange: group was missing secretParams!'
|
||||
);
|
||||
}
|
||||
const clientZkProfileCipher = getClientZkProfileOperations(
|
||||
serverPublicParamsBase64
|
||||
);
|
||||
|
||||
const presentation = createProfileKeyCredentialPresentation(
|
||||
clientZkProfileCipher,
|
||||
profileKeyCredentialBase64,
|
||||
group.secretParams
|
||||
);
|
||||
|
||||
const promotePendingMember = new window.textsecure.protobuf.GroupChange.Actions.PromotePendingMemberAction();
|
||||
promotePendingMember.presentation = presentation;
|
||||
|
||||
actions.version = (group.revision || 0) + 1;
|
||||
actions.promotePendingMembers = [promotePendingMember];
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function uploadGroupChange({
|
||||
actions,
|
||||
group,
|
||||
|
@ -309,11 +401,7 @@ export async function maybeUpdateGroup({
|
|||
// Ensure we have the credentials we need before attempting GroupsV2 operations
|
||||
await maybeFetchNewCredentials();
|
||||
|
||||
const {
|
||||
newAttributes,
|
||||
groupChangeMessages,
|
||||
members,
|
||||
} = await getGroupUpdates({
|
||||
const updates = await getGroupUpdates({
|
||||
group: conversation.attributes,
|
||||
serverPublicParamsBase64: window.getServerPublicParams(),
|
||||
newRevision,
|
||||
|
@ -321,48 +409,7 @@ export async function maybeUpdateGroup({
|
|||
dropInitialJoinMessage,
|
||||
});
|
||||
|
||||
conversation.set(newAttributes);
|
||||
|
||||
// Ensure that all generated messages are ordered properly.
|
||||
// Before the provided timestamp so update messages appear before the
|
||||
// initiating message, or after now().
|
||||
let syntheticTimestamp = receivedAt
|
||||
? receivedAt - (groupChangeMessages.length + 1)
|
||||
: Date.now();
|
||||
// Save all synthetic messages describing group changes
|
||||
const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
|
||||
// We do this to preserve the order of the timeline
|
||||
syntheticTimestamp += 1;
|
||||
|
||||
return {
|
||||
...changeMessage,
|
||||
conversationId: conversation.id,
|
||||
received_at: syntheticTimestamp,
|
||||
sent_at: sentAt,
|
||||
};
|
||||
});
|
||||
|
||||
if (changeMessagesToSave.length > 0) {
|
||||
await window.Signal.Data.saveMessages(changeMessagesToSave, {
|
||||
forceSave: true,
|
||||
});
|
||||
changeMessagesToSave.forEach(changeMessage => {
|
||||
const model = new window.Whisper.Message(changeMessage);
|
||||
window.MessageController.register(model.id, model);
|
||||
conversation.trigger('newmessage', model);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture profile key for each member in the group, if we don't have it yet
|
||||
members.forEach(member => {
|
||||
const contact = window.ConversationController.get(member.uuid);
|
||||
|
||||
if (member.profileKey && contact && !contact.get('profileKey')) {
|
||||
contact.setProfileKey(member.profileKey);
|
||||
}
|
||||
});
|
||||
|
||||
await conversation.updateLastMessage();
|
||||
await updateGroup({ conversation, receivedAt, sentAt, updates });
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
`maybeUpdateGroup/${logId}: Failed to update group:`,
|
||||
|
@ -372,6 +419,79 @@ export async function maybeUpdateGroup({
|
|||
}
|
||||
}
|
||||
|
||||
async function updateGroup({
|
||||
conversation,
|
||||
receivedAt,
|
||||
sentAt,
|
||||
updates,
|
||||
}: {
|
||||
conversation: ConversationModel;
|
||||
receivedAt?: number;
|
||||
sentAt?: number;
|
||||
updates: UpdatesResultType;
|
||||
}): Promise<void> {
|
||||
const { newAttributes, groupChangeMessages, members } = updates;
|
||||
|
||||
const startingRevision = conversation.get('revision');
|
||||
const endingRevision = newAttributes.revision;
|
||||
|
||||
const isInitialDataFetch =
|
||||
!isNumber(startingRevision) && isNumber(endingRevision);
|
||||
|
||||
// Ensure that all generated messages are ordered properly.
|
||||
// Before the provided timestamp so update messages appear before the
|
||||
// initiating message, or after now().
|
||||
let syntheticTimestamp = receivedAt
|
||||
? receivedAt - (groupChangeMessages.length + 1)
|
||||
: Date.now();
|
||||
|
||||
conversation.set({
|
||||
...newAttributes,
|
||||
// We force this conversation into the left pane if this is the first time we've
|
||||
// fetched data about it, and we were able to fetch its name. Nobody likes to see
|
||||
// Unknown Group in the left pane.
|
||||
active_at:
|
||||
isInitialDataFetch && newAttributes.name
|
||||
? syntheticTimestamp
|
||||
: newAttributes.active_at,
|
||||
});
|
||||
|
||||
// Save all synthetic messages describing group changes
|
||||
const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
|
||||
// We do this to preserve the order of the timeline
|
||||
syntheticTimestamp += 1;
|
||||
|
||||
return {
|
||||
...changeMessage,
|
||||
conversationId: conversation.id,
|
||||
received_at: syntheticTimestamp,
|
||||
sent_at: sentAt,
|
||||
};
|
||||
});
|
||||
|
||||
if (changeMessagesToSave.length > 0) {
|
||||
await window.Signal.Data.saveMessages(changeMessagesToSave, {
|
||||
forceSave: true,
|
||||
});
|
||||
changeMessagesToSave.forEach(changeMessage => {
|
||||
const model = new window.Whisper.Message(changeMessage);
|
||||
window.MessageController.register(model.id, model);
|
||||
conversation.trigger('newmessage', model);
|
||||
});
|
||||
}
|
||||
|
||||
// Capture profile key for each member in the group, if we don't have it yet
|
||||
members.forEach(member => {
|
||||
const contact = window.ConversationController.get(member.uuid);
|
||||
|
||||
if (member.profileKey && contact && !contact.get('profileKey')) {
|
||||
contact.setProfileKey(member.profileKey);
|
||||
}
|
||||
});
|
||||
|
||||
// No need for convo.updateLastMessage(), 'newmessage' handler does that
|
||||
}
|
||||
|
||||
function idForLogging(group: ConversationAttributesType) {
|
||||
return `groupv2(${group.groupId})`;
|
||||
}
|
||||
|
@ -396,12 +516,16 @@ async function getGroupUpdates({
|
|||
const currentRevision = group.revision;
|
||||
const isFirstFetch = !isNumber(group.revision);
|
||||
|
||||
const isInitialCreationMessage = isFirstFetch && newRevision === 0;
|
||||
const isOneVersionUp =
|
||||
isNumber(currentRevision) &&
|
||||
isNumber(newRevision) &&
|
||||
newRevision === currentRevision + 1;
|
||||
|
||||
if (
|
||||
groupChangeBase64 &&
|
||||
((isFirstFetch && newRevision === 0) ||
|
||||
(isNumber(newRevision) &&
|
||||
isNumber(currentRevision) &&
|
||||
newRevision === currentRevision + 1))
|
||||
isNumber(newRevision) &&
|
||||
(isInitialCreationMessage || isOneVersionUp)
|
||||
) {
|
||||
window.log.info(`getGroupUpdates/${logId}: Processing just one change`);
|
||||
const groupChangeBuffer = base64ToArrayBuffer(groupChangeBase64);
|
||||
|
@ -700,9 +824,12 @@ async function integrateGroupChanges({
|
|||
for (let j = 0; j < jmax; j += 1) {
|
||||
const changeState = groupChanges[j];
|
||||
|
||||
const { groupChange } = changeState;
|
||||
const { groupChange, groupState } = changeState;
|
||||
|
||||
if (!groupChange) {
|
||||
if (!groupChange || !groupState) {
|
||||
window.log.warn(
|
||||
'integrateGroupChanges: item had neither groupState nor groupChange. Skipping.'
|
||||
);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
@ -717,6 +844,7 @@ async function integrateGroupChanges({
|
|||
group: attributes,
|
||||
newRevision,
|
||||
groupChange,
|
||||
groupState,
|
||||
});
|
||||
|
||||
attributes = newAttributes;
|
||||
|
@ -768,15 +896,19 @@ async function integrateGroupChanges({
|
|||
async function integrateGroupChange({
|
||||
group,
|
||||
groupChange,
|
||||
groupState,
|
||||
newRevision,
|
||||
}: {
|
||||
group: ConversationAttributesType;
|
||||
groupChange: GroupChangeClass;
|
||||
groupState?: GroupClass;
|
||||
newRevision: number;
|
||||
}): Promise<UpdatesResultType> {
|
||||
const logId = idForLogging(group);
|
||||
if (!group.secretParams) {
|
||||
throw new Error('integrateGroupChange: Group was missing secretParams!');
|
||||
throw new Error(
|
||||
`integrateGroupChange/${logId}: Group was missing secretParams!`
|
||||
);
|
||||
}
|
||||
|
||||
const groupChangeActions = window.textsecure.protobuf.GroupChange.Actions.decode(
|
||||
|
@ -804,9 +936,48 @@ async function integrateGroupChange({
|
|||
);
|
||||
const sourceConversationId = sourceConversation.id;
|
||||
|
||||
const isFirstFetch = !isNumber(group.revision);
|
||||
const isMoreThanOneVersionUp =
|
||||
groupChangeActions.version &&
|
||||
isNumber(group.revision) &&
|
||||
groupChangeActions.version > group.revision + 1;
|
||||
|
||||
if (groupState && (isFirstFetch || isMoreThanOneVersionUp)) {
|
||||
window.log.info(
|
||||
`integrateGroupChange/${logId}: Applying full group state, from version ${group.revision} to ${groupState.version}`
|
||||
);
|
||||
|
||||
const decryptedGroupState = decryptGroupState(
|
||||
groupState,
|
||||
group.secretParams,
|
||||
logId
|
||||
);
|
||||
|
||||
const newAttributes = await applyGroupState({
|
||||
group,
|
||||
groupState: decryptedGroupState,
|
||||
sourceConversationId: isFirstFetch ? sourceConversationId : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
newAttributes,
|
||||
groupChangeMessages: extractDiffs({
|
||||
old: group,
|
||||
current: newAttributes,
|
||||
sourceConversationId: isFirstFetch ? sourceConversationId : undefined,
|
||||
}),
|
||||
members: getMembers(decryptedGroupState),
|
||||
};
|
||||
}
|
||||
|
||||
window.log.info(
|
||||
`integrateGroupChange/${logId}: Applying group change actions, from version ${group.revision} to ${groupChangeActions.version}`
|
||||
);
|
||||
|
||||
const { newAttributes, newProfileKeys } = await applyGroupChange({
|
||||
group,
|
||||
actions: decryptedChangeActions,
|
||||
sourceConversationId,
|
||||
});
|
||||
const groupChangeMessages = extractDiffs({
|
||||
old: group,
|
||||
|
@ -861,7 +1032,10 @@ export async function getCurrentGroupState({
|
|||
logId
|
||||
);
|
||||
|
||||
const newAttributes = await applyGroupState(group, decryptedGroupState);
|
||||
const newAttributes = await applyGroupState({
|
||||
group,
|
||||
groupState: decryptedGroupState,
|
||||
});
|
||||
|
||||
return {
|
||||
newAttributes,
|
||||
|
@ -888,7 +1062,10 @@ function extractDiffs({
|
|||
const logId = idForLogging(old);
|
||||
const details: Array<GroupV2ChangeDetailType> = [];
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
let areWeInGroup = false;
|
||||
let areWeInvitedToGroup = false;
|
||||
let whoInvitedUsUserId = null;
|
||||
|
||||
if (
|
||||
current.accessControl &&
|
||||
|
@ -988,6 +1165,11 @@ function extractDiffs({
|
|||
const { conversationId } = currentPendingMember;
|
||||
const oldPendingMember = oldPendingMemberLookup[conversationId];
|
||||
|
||||
if (ourConversationId && conversationId === ourConversationId) {
|
||||
areWeInvitedToGroup = true;
|
||||
whoInvitedUsUserId = currentPendingMember.addedByUserId;
|
||||
}
|
||||
|
||||
if (!oldPendingMember) {
|
||||
lastPendingConversationId = conversationId;
|
||||
count += 1;
|
||||
|
@ -1049,16 +1231,50 @@ function extractDiffs({
|
|||
const sourceUuid = conversation ? conversation.get('uuid') : undefined;
|
||||
|
||||
const firstUpdate = !isNumber(old.revision);
|
||||
const firstEventSourceId = sourceConversationId || ourConversationId;
|
||||
|
||||
// Here we hardcode initial messages if this is our first time processing data this
|
||||
// group. Ideally we can collapse it down to just one of: 'you were added',
|
||||
// 'you were invited', or 'you created.'
|
||||
if (firstUpdate && dropInitialJoinMessage) {
|
||||
message = undefined;
|
||||
} else if (
|
||||
firstUpdate &&
|
||||
ourConversationId &&
|
||||
sourceConversationId &&
|
||||
sourceConversationId === ourConversationId
|
||||
) {
|
||||
message = {
|
||||
...generateBasicMessage(),
|
||||
type: 'group-v2-change',
|
||||
groupV2Change: {
|
||||
from: sourceConversationId,
|
||||
details: [
|
||||
{
|
||||
type: 'create',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (firstUpdate && ourConversationId && areWeInvitedToGroup) {
|
||||
message = {
|
||||
...generateBasicMessage(),
|
||||
type: 'group-v2-change',
|
||||
groupV2Change: {
|
||||
from: whoInvitedUsUserId || sourceConversationId,
|
||||
details: [
|
||||
{
|
||||
type: 'pending-add-one',
|
||||
conversationId: ourConversationId,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (firstUpdate && ourConversationId && areWeInGroup) {
|
||||
message = {
|
||||
...generateBasicMessage(),
|
||||
type: 'group-v2-change',
|
||||
groupV2Change: {
|
||||
from: firstEventSourceId,
|
||||
from: sourceConversationId,
|
||||
details: [
|
||||
{
|
||||
type: 'member-add',
|
||||
|
@ -1067,6 +1283,19 @@ function extractDiffs({
|
|||
],
|
||||
},
|
||||
};
|
||||
} else if (firstUpdate) {
|
||||
message = {
|
||||
...generateBasicMessage(),
|
||||
type: 'group-v2-change',
|
||||
groupV2Change: {
|
||||
from: sourceConversationId,
|
||||
details: [
|
||||
{
|
||||
type: 'create',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (details.length > 0) {
|
||||
message = {
|
||||
...generateBasicMessage(),
|
||||
|
@ -1132,18 +1361,22 @@ type GroupChangeResultType = {
|
|||
};
|
||||
|
||||
async function applyGroupChange({
|
||||
group,
|
||||
actions,
|
||||
group,
|
||||
sourceConversationId,
|
||||
}: {
|
||||
sourceConversationId?: string;
|
||||
group: ConversationAttributesType;
|
||||
actions: GroupChangeClass.Actions;
|
||||
group: ConversationAttributesType;
|
||||
sourceConversationId: string;
|
||||
}): Promise<GroupChangeResultType> {
|
||||
const logId = idForLogging(group);
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
const MEMBER_ROLE_ENUM = window.textsecure.protobuf.Member.Role;
|
||||
|
||||
const version = actions.version || 0;
|
||||
const result = {
|
||||
const result: ConversationAttributesType = {
|
||||
...group,
|
||||
};
|
||||
const newProfileKeys: Array<GroupChangeMemberType> = [];
|
||||
|
@ -1198,6 +1431,15 @@ async function applyGroupChange({
|
|||
delete pendingMembers[conversation.id];
|
||||
}
|
||||
|
||||
// Capture who added us
|
||||
if (
|
||||
ourConversationId &&
|
||||
sourceConversationId &&
|
||||
conversation.id === ourConversationId
|
||||
) {
|
||||
result.addedBy = sourceConversationId;
|
||||
}
|
||||
|
||||
if (added.profileKey) {
|
||||
newProfileKeys.push({
|
||||
profileKey: added.profileKey,
|
||||
|
@ -1438,7 +1680,6 @@ async function applyGroupChange({
|
|||
};
|
||||
}
|
||||
|
||||
const ourConversationId = window.ConversationController.getOurConversationId();
|
||||
if (ourConversationId) {
|
||||
result.left = !members[ourConversationId];
|
||||
}
|
||||
|
@ -1524,14 +1765,19 @@ async function applyNewAvatar(
|
|||
}
|
||||
/* eslint-enable no-param-reassign */
|
||||
|
||||
async function applyGroupState(
|
||||
group: ConversationAttributesType,
|
||||
groupState: GroupClass
|
||||
): Promise<ConversationAttributesType> {
|
||||
async function applyGroupState({
|
||||
group,
|
||||
groupState,
|
||||
sourceConversationId,
|
||||
}: {
|
||||
group: ConversationAttributesType;
|
||||
groupState: GroupClass;
|
||||
sourceConversationId?: string;
|
||||
}): Promise<ConversationAttributesType> {
|
||||
const logId = idForLogging(group);
|
||||
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
||||
const version = groupState.version || 0;
|
||||
const result = {
|
||||
const result: ConversationAttributesType = {
|
||||
...group,
|
||||
};
|
||||
|
||||
|
@ -1589,6 +1835,16 @@ async function applyGroupState(
|
|||
|
||||
if (ourConversationId && conversation.id === ourConversationId) {
|
||||
result.left = false;
|
||||
|
||||
// Capture who added us if we were previously not in group
|
||||
if (
|
||||
sourceConversationId &&
|
||||
(result.membersV2 || []).every(
|
||||
item => item.conversationId !== ourConversationId
|
||||
)
|
||||
) {
|
||||
result.addedBy = sourceConversationId;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue