Groups: Show in left pane more often, proper join message

This commit is contained in:
Scott Nonnenberg 2022-05-16 07:53:54 -07:00 committed by GitHub
parent 4e76259917
commit dfd1190e8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 403 additions and 276 deletions

View file

@ -40,6 +40,7 @@ try {
window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING = true; window.GV2_ENABLE_SINGLE_CHANGE_PROCESSING = true;
window.GV2_ENABLE_CHANGE_PROCESSING = true; window.GV2_ENABLE_CHANGE_PROCESSING = true;
window.GV2_ENABLE_STATE_PROCESSING = true; window.GV2_ENABLE_STATE_PROCESSING = true;
window.GV2_ENABLE_PRE_JOIN_FETCH = true;
window.GV2_MIGRATION_DISABLE_ADD = false; window.GV2_MIGRATION_DISABLE_ADD = false;
window.GV2_MIGRATION_DISABLE_INVITE = false; window.GV2_MIGRATION_DISABLE_INVITE = false;

View file

@ -343,7 +343,7 @@ export async function getPreJoinGroupInfo(
); );
return makeRequestWithTemporalRetry({ return makeRequestWithTemporalRetry({
logId: `groupv2(${data.id})`, logId: `getPreJoinInfo/groupv2(${data.id})`,
publicParams: Bytes.toBase64(data.publicParams), publicParams: Bytes.toBase64(data.publicParams),
secretParams: Bytes.toBase64(data.secretParams), secretParams: Bytes.toBase64(data.secretParams),
request: (sender, options) => request: (sender, options) =>
@ -2474,6 +2474,7 @@ export async function joinGroupV2ViaLinkAndMigrate({
secretParams, secretParams,
groupInviteLinkPassword: inviteLinkPassword, groupInviteLinkPassword: inviteLinkPassword,
addedBy: undefined,
left: true, left: true,
// Capture previous GroupV1 data for future use // Capture previous GroupV1 data for future use
@ -2637,6 +2638,7 @@ export async function respondToGroupV2Migration({
newAttributes: { newAttributes: {
// Because we're using attributes here, we upgrade this to a v2 group // Because we're using attributes here, we upgrade this to a v2 group
...attributes, ...attributes,
addedBy: undefined,
left: true, left: true,
members: (conversation.get('members') || []).filter( members: (conversation.get('members') || []).filter(
item => item !== ourUuid && item !== ourNumber item => item !== ourUuid && item !== ourNumber
@ -2794,7 +2796,7 @@ const FIVE_MINUTES = 5 * durations.MINUTE;
export async function waitThenMaybeUpdateGroup( export async function waitThenMaybeUpdateGroup(
options: MaybeUpdatePropsType, options: MaybeUpdatePropsType,
{ viaSync = false } = {} { viaFirstStorageSync = false } = {}
): Promise<void> { ): Promise<void> {
const { conversation } = options; const { conversation } = options;
@ -2826,7 +2828,7 @@ export async function waitThenMaybeUpdateGroup(
await conversation.queueJob('waitThenMaybeUpdateGroup', async () => { await conversation.queueJob('waitThenMaybeUpdateGroup', async () => {
try { try {
// And finally try to update the group // And finally try to update the group
await maybeUpdateGroup(options, { viaSync }); await maybeUpdateGroup(options, { viaFirstStorageSync });
conversation.lastSuccessfulGroupFetch = Date.now(); conversation.lastSuccessfulGroupFetch = Date.now();
} catch (error) { } catch (error) {
@ -2847,7 +2849,7 @@ export async function maybeUpdateGroup(
receivedAt, receivedAt,
sentAt, sentAt,
}: MaybeUpdatePropsType, }: MaybeUpdatePropsType,
{ viaSync = false } = {} { viaFirstStorageSync = false } = {}
): Promise<void> { ): Promise<void> {
const logId = conversation.idForLogging(); const logId = conversation.idForLogging();
@ -2865,7 +2867,7 @@ export async function maybeUpdateGroup(
await updateGroup( await updateGroup(
{ conversation, receivedAt, sentAt, updates }, { conversation, receivedAt, sentAt, updates },
{ viaSync } { viaFirstStorageSync }
); );
} catch (error) { } catch (error) {
log.error( log.error(
@ -2888,21 +2890,27 @@ async function updateGroup(
sentAt?: number; sentAt?: number;
updates: UpdatesResultType; updates: UpdatesResultType;
}, },
{ viaSync = false } = {} { viaFirstStorageSync = false } = {}
): Promise<void> { ): Promise<void> {
const logId = conversation.idForLogging(); const logId = conversation.idForLogging();
const { newAttributes, groupChangeMessages, members } = updates; const { newAttributes, groupChangeMessages, members } = updates;
const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const startingRevision = conversation.get('revision'); const startingRevision = conversation.get('revision');
const endingRevision = newAttributes.revision; const endingRevision = newAttributes.revision;
const isInGroup = !updates.newAttributes.left; const wasMemberOrPending =
conversation.hasMember(ourUuid) || conversation.isMemberPending(ourUuid);
const isMemberOrPending =
!newAttributes.left ||
newAttributes.pendingMembersV2?.some(item => item.uuid === ourUuid);
const isMemberOrPendingOrAwaitingApproval =
isMemberOrPending ||
newAttributes.pendingAdminApprovalV2?.some(item => item.uuid === ourUuid);
const isInitialDataFetch = const isInitialDataFetch =
isInGroup && !isNumber(startingRevision) && isNumber(endingRevision); !isNumber(startingRevision) && isNumber(endingRevision);
const justJoinedGroup =
isInGroup && !conversation.hasMember(ourUuid.toString());
// Ensure that all generated messages are ordered properly. // Ensure that all generated messages are ordered properly.
// Before the provided timestamp so update messages appear before the // Before the provided timestamp so update messages appear before the
@ -2916,16 +2924,18 @@ async function updateGroup(
const previousId = conversation.get('groupId'); const previousId = conversation.get('groupId');
const idChanged = previousId && previousId !== newAttributes.groupId; const idChanged = previousId && previousId !== newAttributes.groupId;
// We force this conversation into the left pane if this is the first time we've // By updating activeAt we force this conversation into the left pane if this is the
// fetched data about it, and we were able to fetch its name. Nobody likes to see // first time we've fetched data about it, and we were able to fetch its name. Nobody
// Unknown Group in the left pane. // likes to see Unknown Group in the left pane. After first fetch, we rely on normal
let activeAt = null; // message activity (including group change messsages) to set the timestamp properly.
if (viaSync) { let activeAt = conversation.get('active_at') || null;
activeAt = conversation.get('active_at') || null; if (
} else if ((isInitialDataFetch || justJoinedGroup) && newAttributes.name) { !viaFirstStorageSync &&
isMemberOrPendingOrAwaitingApproval &&
isInitialDataFetch &&
newAttributes.name
) {
activeAt = initialSentAt; activeAt = initialSentAt;
} else {
activeAt = newAttributes.active_at;
} }
// Save all synthetic messages describing group changes // Save all synthetic messages describing group changes
@ -3003,7 +3013,7 @@ async function updateGroup(
conversation.set({ conversation.set({
...newAttributes, ...newAttributes,
active_at: activeAt, active_at: activeAt,
temporaryMemberCount: isInGroup temporaryMemberCount: !newAttributes.left
? undefined ? undefined
: newAttributes.temporaryMemberCount, : newAttributes.temporaryMemberCount,
}); });
@ -3014,6 +3024,37 @@ async function updateGroup(
// Save these most recent updates to conversation // Save these most recent updates to conversation
await updateConversation(conversation.attributes); await updateConversation(conversation.attributes);
// If we've been added by a blocked contact, then schedule a task to leave group
const justAdded = !wasMemberOrPending && isMemberOrPending;
const addedBy =
newAttributes.pendingMembersV2?.find(item => item.uuid === ourUuid)
?.addedByUserId || newAttributes.addedBy;
if (justAdded && addedBy) {
const adder = window.ConversationController.get(addedBy);
if (adder && adder.isBlocked()) {
log.warn(
`updateGroup/${logId}: Added to group by blocked user ${adder.idForLogging()}. Scheduling group leave.`
);
// Wait for empty queue to make it more likely the group update succeeds
const waitThenLeave = async () => {
log.warn(`waitThenLeave/${logId}: Waiting for empty event queue.`);
await window.waitForEmptyEventQueue();
log.warn(
`waitThenLeave/${logId}: Empty event queue, starting group leave.`
);
await conversation.leaveGroupV2();
log.warn(`waitThenLeave/${logId}: Leave complete.`);
};
// Cannot await here, would infinitely block queue
waitThenLeave();
}
}
} }
// Exported for testing // Exported for testing
@ -3295,7 +3336,6 @@ async function getGroupUpdates({
group, group,
newRevision, newRevision,
groupChange, groupChange,
serverPublicParamsBase64,
}); });
} }
@ -3309,13 +3349,10 @@ async function getGroupUpdates({
window.GV2_ENABLE_CHANGE_PROCESSING window.GV2_ENABLE_CHANGE_PROCESSING
) { ) {
try { try {
const result = await updateGroupViaLogs({ return await updateGroupViaLogs({
group, group,
serverPublicParamsBase64,
newRevision, newRevision,
}); });
return result;
} catch (error) { } catch (error) {
const nextStep = isFirstFetch const nextStep = isFirstFetch
? `fetching logs since ${newRevision}` ? `fetching logs since ${newRevision}`
@ -3338,11 +3375,44 @@ async function getGroupUpdates({
} }
if (window.GV2_ENABLE_STATE_PROCESSING) { if (window.GV2_ENABLE_STATE_PROCESSING) {
return updateGroupViaState({ try {
dropInitialJoinMessage, return await updateGroupViaState({
group, dropInitialJoinMessage,
serverPublicParamsBase64, group,
}); });
} catch (error) {
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
log.info(
`getGroupUpdates/${logId}: Temporal credential failure. Failing; we don't know if we have access or not.`
);
throw error;
} else if (error.code === GROUP_ACCESS_DENIED_CODE) {
// We will fail over to the updateGroupViaPreJoinInfo call below
log.info(
`getGroupUpdates/${logId}: Failed to get group state. Attempting to fetch pre-join information.`
);
} else {
throw error;
}
}
}
if (window.GV2_ENABLE_PRE_JOIN_FETCH) {
try {
return await updateGroupViaPreJoinInfo({
group,
});
} catch (error) {
if (error.code === GROUP_ACCESS_DENIED_CODE) {
return generateLeftGroupChanges(group);
}
if (error.code === GROUP_NONEXISTENT_CODE) {
return generateLeftGroupChanges(group);
}
// If we get another temporal failure, we'll fail and try again later.
throw error;
}
} }
log.warn( log.warn(
@ -3355,70 +3425,141 @@ async function getGroupUpdates({
}; };
} }
async function updateGroupViaState({ async function updateGroupViaPreJoinInfo({
dropInitialJoinMessage,
group, group,
serverPublicParamsBase64,
}: { }: {
dropInitialJoinMessage?: boolean;
group: ConversationAttributesType; group: ConversationAttributesType;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId); const logId = idForLogging(group.groupId);
const data = window.storage.get(GROUP_CREDENTIALS_KEY); const data = window.storage.get(GROUP_CREDENTIALS_KEY);
if (!data) { if (!data) {
throw new Error('updateGroupViaState: No group credentials!'); throw new Error('updateGroupViaPreJoinInfo: No group credentials!');
}
const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString();
const { publicParams, secretParams } = group;
if (!secretParams) {
throw new Error(
'updateGroupViaPreJoinInfo: group was missing secretParams!'
);
}
if (!publicParams) {
throw new Error(
'updateGroupViaPreJoinInfo: group was missing publicParams!'
);
} }
const groupCredentials = getCredentialsForToday(data); // No password, but if we're already pending approval, we can access this without it.
const inviteLinkPassword = undefined;
const preJoinInfo = await makeRequestWithTemporalRetry({
logId: `getPreJoinInfo/${logId}`,
publicParams,
secretParams,
request: (sender, options) =>
sender.getGroupFromLink(inviteLinkPassword, options),
});
const stateOptions = { const approvalRequired =
dropInitialJoinMessage, preJoinInfo.addFromInviteLink ===
group, Proto.AccessControl.AccessRequired.ADMINISTRATOR;
serverPublicParamsBase64,
authCredentialBase64: groupCredentials.today.credential, // If the group doesn't require approval to join via link, then we should never have
// gotten here.
if (!approvalRequired) {
return generateLeftGroupChanges(group);
}
const newAttributes: ConversationAttributesType = {
...group,
description: decryptGroupDescription(
preJoinInfo.descriptionBytes,
secretParams
),
name: decryptGroupTitle(preJoinInfo.title, secretParams),
members: [],
pendingMembersV2: [],
pendingAdminApprovalV2: [
{
uuid: ourUuid,
timestamp: Date.now(),
},
],
revision: preJoinInfo.version,
temporaryMemberCount: preJoinInfo.memberCount || 1,
}; };
try {
log.info(`updateGroupViaState/${logId}: Getting full group state...`);
// We await this here so our try/catch below takes effect
const result = await getCurrentGroupState(stateOptions);
return result; await applyNewAvatar(dropNull(preJoinInfo.avatar), newAttributes, logId);
} catch (error) {
if (error.code === GROUP_ACCESS_DENIED_CODE) {
return generateLeftGroupChanges(group);
}
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
log.info(
`updateGroupViaState/${logId}: Credential for today failed, failing over to tomorrow...`
);
try {
const result = await getCurrentGroupState({
...stateOptions,
authCredentialBase64: groupCredentials.tomorrow.credential,
});
return result;
} catch (subError) {
if (subError.code === GROUP_ACCESS_DENIED_CODE) {
return generateLeftGroupChanges(group);
}
}
}
throw error; return {
newAttributes,
groupChangeMessages: extractDiffs({
old: group,
current: newAttributes,
dropInitialJoinMessage: false,
}),
members: [],
};
}
async function updateGroupViaState({
dropInitialJoinMessage,
group,
}: {
dropInitialJoinMessage?: boolean;
group: ConversationAttributesType;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
const { publicParams, secretParams } = group;
if (!secretParams) {
throw new Error('updateGroupViaState: group was missing secretParams!');
} }
if (!publicParams) {
throw new Error('updateGroupViaState: group was missing publicParams!');
}
const groupState = await makeRequestWithTemporalRetry({
logId: `getGroup/${logId}`,
publicParams,
secretParams,
request: (sender, requestOptions) => sender.getGroup(requestOptions),
});
const decryptedGroupState = decryptGroupState(
groupState,
secretParams,
logId
);
const oldVersion = group.revision;
const newVersion = decryptedGroupState.version;
log.info(
`getCurrentGroupState/${logId}: Applying full group state, from version ${oldVersion} to ${newVersion}.`
);
const { newAttributes, newProfileKeys } = await applyGroupState({
group,
groupState: decryptedGroupState,
});
return {
newAttributes,
groupChangeMessages: extractDiffs({
old: group,
current: newAttributes,
dropInitialJoinMessage,
}),
members: profileKeysToMembers(newProfileKeys),
};
} }
async function updateGroupViaSingleChange({ async function updateGroupViaSingleChange({
group, group,
groupChange, groupChange,
newRevision, newRevision,
serverPublicParamsBase64,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
groupChange: Proto.IGroupChange; groupChange: Proto.IGroupChange;
newRevision: number; newRevision: number;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
const wasInGroup = !group.left; const wasInGroup = !group.left;
const result: UpdatesResultType = await integrateGroupChange({ const result: UpdatesResultType = await integrateGroupChange({
@ -3434,7 +3575,6 @@ async function updateGroupViaSingleChange({
if (!wasInGroup && nowInGroup) { if (!wasInGroup && nowInGroup) {
const { newAttributes, members } = await updateGroupViaState({ const { newAttributes, members } = await updateGroupViaState({
group: result.newAttributes, group: result.newAttributes,
serverPublicParamsBase64,
}); });
// We discard any change events that come out of this full group fetch, but we do // We discard any change events that come out of this full group fetch, but we do
@ -3451,48 +3591,73 @@ async function updateGroupViaSingleChange({
async function updateGroupViaLogs({ async function updateGroupViaLogs({
group, group,
serverPublicParamsBase64,
newRevision, newRevision,
}: { }: {
group: ConversationAttributesType; group: ConversationAttributesType;
newRevision: number | undefined; newRevision: number | undefined;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> { }): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId); const logId = idForLogging(group.groupId);
const data = window.storage.get(GROUP_CREDENTIALS_KEY); const { publicParams, secretParams } = group;
if (!data) { if (!publicParams) {
throw new Error('getGroupUpdates: No group credentials!'); throw new Error('updateGroupViaLogs: group was missing publicParams!');
}
if (!secretParams) {
throw new Error('updateGroupViaLogs: group was missing secretParams!');
} }
const groupCredentials = getCredentialsForToday(data); log.info(
const deltaOptions = { `updateGroupViaLogs/${logId}: Getting group delta from ` +
`${group.revision ?? '?'} to ${newRevision ?? '?'} for group ` +
`groupv2(${group.groupId})...`
);
const currentRevision = group.revision;
let includeFirstState = true;
// The range is inclusive so make sure that we always request the revision
// that we are currently at since we might want the latest full state in
// `integrateGroupChanges`.
let revisionToFetch = isNumber(currentRevision) ? currentRevision : undefined;
let response;
const changes: Array<Proto.IGroupChanges> = [];
do {
// eslint-disable-next-line no-await-in-loop
response = await makeRequestWithTemporalRetry({
logId: `getGroupLog/${logId}`,
publicParams,
secretParams,
// eslint-disable-next-line no-loop-func
request: (sender, requestOptions) =>
sender.getGroupLog(
{
startVersion: revisionToFetch,
includeFirstState,
includeLastState: true,
maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH,
},
requestOptions
),
});
changes.push(response.changes);
if (response.end) {
revisionToFetch = response.end + 1;
}
includeFirstState = false;
} while (
response.end &&
(newRevision === undefined || response.end < newRevision)
);
// Would be nice to cache the unused groupChanges here, to reduce server roundtrips
return integrateGroupChanges({
changes,
group, group,
newRevision, newRevision,
serverPublicParamsBase64, });
authCredentialBase64: groupCredentials.today.credential,
};
try {
log.info(
`updateGroupViaLogs/${logId}: Getting group delta from ` +
`${group.revision ?? '?'} to ${newRevision ?? '?'} for group ` +
`groupv2(${group.groupId})...`
);
const result = await getGroupDelta(deltaOptions);
return result;
} catch (error) {
if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
log.info(
`updateGroupViaLogs/${logId}: Credential for today failed, failing over to tomorrow...`
);
return getGroupDelta({
...deltaOptions,
authCredentialBase64: groupCredentials.tomorrow.credential,
});
}
throw error;
}
} }
async function generateLeftGroupChanges( async function generateLeftGroupChanges(
@ -3524,34 +3689,28 @@ async function generateLeftGroupChanges(
); );
} }
const existingMembers = group.membersV2 || [];
const newAttributes: ConversationAttributesType = { const newAttributes: ConversationAttributesType = {
...group, ...group,
membersV2: existingMembers.filter(member => member.uuid !== ourUuid), addedBy: undefined,
membersV2: (group.membersV2 || []).filter(
member => member.uuid !== ourUuid
),
pendingMembersV2: (group.pendingMembersV2 || []).filter(
member => member.uuid !== ourUuid
),
pendingAdminApprovalV2: (group.pendingAdminApprovalV2 || []).filter(
member => member.uuid !== ourUuid
),
left: true, left: true,
revision, revision,
}; };
const isNewlyRemoved =
existingMembers.length > (newAttributes.membersV2 || []).length;
const youWereRemovedMessage: GroupChangeMessageType = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
details: [
{
type: 'member-remove' as const,
uuid: ourUuid,
},
],
},
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
};
return { return {
newAttributes, newAttributes,
groupChangeMessages: isNewlyRemoved ? [youWereRemovedMessage] : [], groupChangeMessages: extractDiffs({
current: newAttributes,
old: group,
}),
members: [], members: [],
}; };
} }
@ -3583,76 +3742,6 @@ function getGroupCredentials({
}; };
} }
async function getGroupDelta({
group,
newRevision,
serverPublicParamsBase64,
authCredentialBase64,
}: {
group: ConversationAttributesType;
newRevision: number | undefined;
serverPublicParamsBase64: string;
authCredentialBase64: string;
}): Promise<UpdatesResultType> {
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error('getGroupDelta: textsecure.messaging is not available!');
}
if (!group.publicParams) {
throw new Error('getGroupDelta: group was missing publicParams!');
}
if (!group.secretParams) {
throw new Error('getGroupDelta: group was missing secretParams!');
}
const options = getGroupCredentials({
authCredentialBase64,
groupPublicParamsBase64: group.publicParams,
groupSecretParamsBase64: group.secretParams,
serverPublicParamsBase64,
});
const currentRevision = group.revision;
let includeFirstState = true;
// The range is inclusive so make sure that we always request the revision
// that we are currently at since we might want the latest full state in
// `integrateGroupChanges`.
let revisionToFetch = isNumber(currentRevision) ? currentRevision : undefined;
let response;
const changes: Array<Proto.IGroupChanges> = [];
do {
// eslint-disable-next-line no-await-in-loop
response = await sender.getGroupLog(
{
startVersion: revisionToFetch,
includeFirstState,
includeLastState: true,
maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH,
},
options
);
changes.push(response.changes);
if (response.end) {
revisionToFetch = response.end + 1;
}
includeFirstState = false;
} while (
response.end &&
(newRevision === undefined || response.end < newRevision)
);
// Would be nice to cache the unused groupChanges here, to reduce server roundtrips
return integrateGroupChanges({
changes,
group,
newRevision,
});
}
async function integrateGroupChanges({ async function integrateGroupChanges({
group, group,
newRevision, newRevision,
@ -3840,7 +3929,10 @@ async function integrateGroupChange({
} }
if (groupChangeActions.version === group.revision) { if (groupChangeActions.version === group.revision) {
isSameVersion = true; isSameVersion = true;
} else if (groupChangeActions.version > group.revision + 1) { } else if (
groupChangeActions.version > group.revision + 1 ||
(!isNumber(group.revision) && groupChangeActions.version > 0)
) {
isMoreThanOneVersionUp = true; isMoreThanOneVersionUp = true;
} }
} }
@ -3956,64 +4048,6 @@ async function integrateGroupChange({
}; };
} }
async function getCurrentGroupState({
authCredentialBase64,
dropInitialJoinMessage,
group,
serverPublicParamsBase64,
}: {
authCredentialBase64: string;
dropInitialJoinMessage?: boolean;
group: ConversationAttributesType;
serverPublicParamsBase64: string;
}): Promise<UpdatesResultType> {
const logId = idForLogging(group.groupId);
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error('textsecure.messaging is not available!');
}
if (!group.secretParams) {
throw new Error('getCurrentGroupState: group was missing secretParams!');
}
if (!group.publicParams) {
throw new Error('getCurrentGroupState: group was missing publicParams!');
}
const options = getGroupCredentials({
authCredentialBase64,
groupPublicParamsBase64: group.publicParams,
groupSecretParamsBase64: group.secretParams,
serverPublicParamsBase64,
});
const groupState = await sender.getGroup(options);
const decryptedGroupState = decryptGroupState(
groupState,
group.secretParams,
logId
);
const oldVersion = group.revision;
const newVersion = decryptedGroupState.version;
log.info(
`getCurrentGroupState/${logId}: Applying full group state, from version ${oldVersion} to ${newVersion}.`
);
const { newAttributes, newProfileKeys } = await applyGroupState({
group,
groupState: decryptedGroupState,
});
return {
newAttributes,
groupChangeMessages: extractDiffs({
old: group,
current: newAttributes,
dropInitialJoinMessage,
}),
members: profileKeysToMembers(newProfileKeys),
};
}
function extractDiffs({ function extractDiffs({
current, current,
dropInitialJoinMessage, dropInitialJoinMessage,
@ -4032,6 +4066,7 @@ function extractDiffs({
let areWeInGroup = false; let areWeInGroup = false;
let areWeInvitedToGroup = false; let areWeInvitedToGroup = false;
let areWePendingApproval = false;
let whoInvitedUsUserId = null; let whoInvitedUsUserId = null;
// access control // access control
@ -4286,6 +4321,10 @@ function extractDiffs({
const { uuid } = currentPendingAdminAprovalMember; const { uuid } = currentPendingAdminAprovalMember;
const oldPendingMember = oldPendingAdminApprovalLookup.get(uuid); const oldPendingMember = oldPendingAdminApprovalLookup.get(uuid);
if (uuid === ourUuid) {
areWePendingApproval = true;
}
if (!oldPendingMember) { if (!oldPendingMember) {
details.push({ details.push({
type: 'admin-approval-add-one', type: 'admin-approval-add-one',
@ -4349,10 +4388,29 @@ function extractDiffs({
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen, seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
}; };
} else if (firstUpdate && areWePendingApproval) {
message = {
...generateBasicMessage(),
type: 'group-v2-change',
groupV2Change: {
from: ourUuid,
details: [
{
type: 'admin-approval-add-one',
uuid: ourUuid,
},
],
},
};
} else if (firstUpdate && dropInitialJoinMessage) { } else if (firstUpdate && dropInitialJoinMessage) {
// None of the rest of the messages should be added if dropInitialJoinMessage = true // None of the rest of the messages should be added if dropInitialJoinMessage = true
message = undefined; message = undefined;
} else if (firstUpdate && sourceUuid && sourceUuid === ourUuid) { } else if (
firstUpdate &&
current.revision === 0 &&
sourceUuid &&
sourceUuid === ourUuid
) {
message = { message = {
...generateBasicMessage(), ...generateBasicMessage(),
type: 'group-v2-change', type: 'group-v2-change',
@ -4383,7 +4441,7 @@ function extractDiffs({
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen, seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
}; };
} else if (firstUpdate) { } else if (firstUpdate && current.revision === 0) {
message = { message = {
...generateBasicMessage(), ...generateBasicMessage(),
type: 'group-v2-change', type: 'group-v2-change',
@ -4944,6 +5002,9 @@ async function applyGroupChange({
if (ourUuid) { if (ourUuid) {
result.left = !members[ourUuid]; result.left = !members[ourUuid];
} }
if (result.left) {
result.addedBy = undefined;
}
// Go from lookups back to arrays // Go from lookups back to arrays
result.membersV2 = values(members); result.membersV2 = values(members);
@ -5092,6 +5153,9 @@ async function applyGroupState({
const ourUuid = window.storage.user.getCheckedUuid().toString(); const ourUuid = window.storage.user.getCheckedUuid().toString();
// members // members
const wasPreviouslyAMember = (result.membersV2 || []).some(
item => item.uuid !== ourUuid
);
if (groupState.members) { if (groupState.members) {
result.membersV2 = groupState.members.map(member => { result.membersV2 = groupState.members.map(member => {
if (member.userId === ourUuid) { if (member.userId === ourUuid) {
@ -5100,7 +5164,9 @@ async function applyGroupState({
// Capture who added us if we were previously not in group // Capture who added us if we were previously not in group
if ( if (
sourceUuid && sourceUuid &&
(result.membersV2 || []).every(item => item.uuid !== ourUuid) !wasPreviouslyAMember &&
isNumber(member.joinedAtVersion) &&
member.joinedAtVersion === version
) { ) {
result.addedBy = sourceUuid; result.addedBy = sourceUuid;
} }
@ -5207,6 +5273,10 @@ async function applyGroupState({
// membersBanned // membersBanned
result.bannedMembersV2 = groupState.membersBanned; result.bannedMembersV2 = groupState.membersBanned;
if (result.left) {
result.addedBy = undefined;
}
return { return {
newAttributes: result, newAttributes: result,
newProfileKeys, newProfileKeys,

View file

@ -27,6 +27,7 @@ import { ToastAlreadyGroupMember } from '../components/ToastAlreadyGroupMember';
import { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequestedToJoin'; import { ToastAlreadyRequestedToJoin } from '../components/ToastAlreadyRequestedToJoin';
import { HTTPError } from '../textsecure/Errors'; import { HTTPError } from '../textsecure/Errors';
import { isAccessControlEnabled } from './util'; import { isAccessControlEnabled } from './util';
import { sleep } from '../util/sleep';
export async function joinViaLink(hash: string): Promise<void> { export async function joinViaLink(hash: string): Promise<void> {
let inviteLinkPassword: string; let inviteLinkPassword: string;
@ -153,6 +154,15 @@ export async function joinViaLink(hash: string): Promise<void> {
log.warn( log.warn(
`joinViaLink/${logId}: Already awaiting approval, opening conversation` `joinViaLink/${logId}: Already awaiting approval, opening conversation`
); );
const timestamp = existingConversation.get('timestamp') || Date.now();
// eslint-disable-next-line camelcase
const active_at = existingConversation.get('active_at') || Date.now();
existingConversation.set({ active_at, timestamp });
window.Signal.Data.updateConversation(existingConversation.attributes);
// We're waiting for the left pane to re-sort before we navigate to that conversation
await sleep(200);
window.reduxActions.conversations.openConversationInternal({ window.reduxActions.conversations.openConversationInternal({
conversationId: existingConversation.id, conversationId: existingConversation.id,
}); });
@ -257,6 +267,9 @@ export async function joinViaLink(hash: string): Promise<void> {
// This will cause this conversation to be deleted at next startup // This will cause this conversation to be deleted at next startup
isTemporary: true, isTemporary: true,
active_at: Date.now(),
timestamp: Date.now(),
groupVersion: 2, groupVersion: 2,
masterKey, masterKey,
secretParams, secretParams,
@ -272,6 +285,7 @@ export async function joinViaLink(hash: string): Promise<void> {
path: localAvatar.path, path: localAvatar.path,
} }
: undefined, : undefined,
description: groupDescription,
groupInviteLinkPassword: inviteLinkPassword, groupInviteLinkPassword: inviteLinkPassword,
name: title, name: title,
temporaryMemberCount: memberCount, temporaryMemberCount: memberCount,
@ -281,7 +295,13 @@ export async function joinViaLink(hash: string): Promise<void> {
} else { } else {
// Ensure the group maintains the title and avatar you saw when attempting // Ensure the group maintains the title and avatar you saw when attempting
// to join it. // to join it.
const timestamp =
targetConversation.get('timestamp') || Date.now();
// eslint-disable-next-line camelcase
const active_at =
targetConversation.get('active_at') || Date.now();
targetConversation.set({ targetConversation.set({
active_at,
avatar: avatar:
localAvatar && localAvatar.path && result.avatar localAvatar && localAvatar.path && result.avatar
? { ? {
@ -289,9 +309,12 @@ export async function joinViaLink(hash: string): Promise<void> {
path: localAvatar.path, path: localAvatar.path,
} }
: undefined, : undefined,
description: groupDescription,
groupInviteLinkPassword: inviteLinkPassword, groupInviteLinkPassword: inviteLinkPassword,
name: title, name: title,
revision: result.version,
temporaryMemberCount: memberCount, temporaryMemberCount: memberCount,
timestamp,
}); });
window.Signal.Data.updateConversation( window.Signal.Data.updateConversation(
targetConversation.attributes targetConversation.attributes

View file

@ -2277,7 +2277,9 @@ export class ConversationModel extends window.Backbone
const inviteLinkPassword = this.get('groupInviteLinkPassword'); const inviteLinkPassword = this.get('groupInviteLinkPassword');
if (!inviteLinkPassword) { if (!inviteLinkPassword) {
throw new Error('Missing groupInviteLinkPassword!'); log.warn(
`cancelJoinRequest/${this.idForLogging()}: We don't have an inviteLinkPassword!`
);
} }
await this.modifyGroupV2({ await this.modifyGroupV2({

View file

@ -2019,9 +2019,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = window.ConversationController.get(conversationId)!; const conversation = window.ConversationController.get(conversationId)!;
const idLog = conversation.idForLogging();
await conversation.queueJob('handleDataMessage', async () => { await conversation.queueJob('handleDataMessage', async () => {
log.info( log.info(
`Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` `handleDataMessage/${idLog}: processsing message ${message.idForLogging()}`
); );
if ( if (
@ -2031,7 +2032,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}) })
) { ) {
log.info( log.info(
'handleDataMessage: dropping story from !accepted', `handleDataMessage/${idLog}: dropping story from !accepted`,
this.getSenderIdentifier() this.getSenderIdentifier()
); );
confirm(); confirm();
@ -2043,10 +2044,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.getSenderIdentifier() this.getSenderIdentifier()
); );
if (inMemoryMessage) { if (inMemoryMessage) {
log.info('handleDataMessage: cache hit', this.getSenderIdentifier()); log.info(
`handleDataMessage/${idLog}: cache hit`,
this.getSenderIdentifier()
);
} else { } else {
log.info( log.info(
'handleDataMessage: duplicate check db lookup needed', `handleDataMessage/${idLog}: duplicate check db lookup needed`,
this.getSenderIdentifier() this.getSenderIdentifier()
); );
} }
@ -2055,14 +2059,17 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const isUpdate = Boolean(data && data.isRecipientUpdate); const isUpdate = Boolean(data && data.isRecipientUpdate);
if (existingMessage && type === 'incoming') { if (existingMessage && type === 'incoming') {
log.warn('Received duplicate message', this.idForLogging()); log.warn(
`handleDataMessage/${idLog}: Received duplicate message`,
this.idForLogging()
);
confirm(); confirm();
return; return;
} }
if (type === 'outgoing') { if (type === 'outgoing') {
if (isUpdate && existingMessage) { if (isUpdate && existingMessage) {
log.info( log.info(
`handleDataMessage: Updating message ${message.idForLogging()} with received transcript` `handleDataMessage/${idLog}: Updating message ${message.idForLogging()} with received transcript`
); );
const toUpdate = window.MessageController.register( const toUpdate = window.MessageController.register(
@ -2139,7 +2146,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
if (isUpdate) { if (isUpdate) {
log.warn( log.warn(
`handleDataMessage: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.` `handleDataMessage/${idLog}: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.`
); );
confirm(); confirm();
@ -2147,7 +2154,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
if (existingMessage) { if (existingMessage) {
log.warn( log.warn(
`handleDataMessage: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.` `handleDataMessage/${idLog}: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.`
); );
confirm(); confirm();
@ -2212,7 +2219,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} catch (error) { } catch (error) {
const errorText = error && error.stack ? error.stack : error; const errorText = error && error.stack ? error.stack : error;
log.error( log.error(
`handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}` `handleDataMessage/${idLog}: Failed to process group update as part of message ${message.idForLogging()}: ${errorText}`
); );
throw error; throw error;
} }
@ -2233,6 +2240,19 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
initialMessage.group && initialMessage.group &&
initialMessage.group.type !== Proto.GroupContext.Type.DELIVER; initialMessage.group.type !== Proto.GroupContext.Type.DELIVER;
// Drop if from blocked user. Only GroupV2 messages should need to be dropped here.
const isBlocked =
(source && window.storage.blocked.isBlocked(source)) ||
(sourceUuid && window.storage.blocked.isUuidBlocked(sourceUuid));
if (isBlocked) {
log.info(
`handleDataMessage/${idLog}: Dropping message from blocked sender. hasGroupV2Prop: ${hasGroupV2Prop}`
);
confirm();
return;
}
// Drop an incoming GroupV2 message if we or the sender are not part of the group // Drop an incoming GroupV2 message if we or the sender are not part of the group
// after applying the message's associated group changes. // after applying the message's associated group changes.
if ( if (

View file

@ -744,7 +744,7 @@ export async function mergeGroupV2Record(
conversation, conversation,
dropInitialJoinMessage, dropInitialJoinMessage,
}, },
{ viaSync: true } { viaFirstStorageSync: isFirstSync }
); );
} }

View file

@ -1238,9 +1238,13 @@ export default class MessageReceiver
// Note: we need to process this as part of decryption, because we might need this // Note: we need to process this as part of decryption, because we might need this
// sender key to decrypt the next message in the queue! // sender key to decrypt the next message in the queue!
let isGroupV2 = false;
try { try {
const content = Proto.Content.decode(plaintext); const content = Proto.Content.decode(plaintext);
isGroupV2 = Boolean(content.dataMessage?.groupV2);
if ( if (
content.senderKeyDistributionMessage && content.senderKeyDistributionMessage &&
Bytes.isNotEmpty(content.senderKeyDistributionMessage) Bytes.isNotEmpty(content.senderKeyDistributionMessage)
@ -1258,12 +1262,14 @@ export default class MessageReceiver
); );
} }
// We want to process GroupV2 updates, even from blocked users. We'll drop them later.
if ( if (
(envelope.source && this.isBlocked(envelope.source)) || !isGroupV2 &&
(envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)) ((envelope.source && this.isBlocked(envelope.source)) ||
(envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)))
) { ) {
log.info( log.info(
'MessageReceiver.decryptEnvelope: Dropping message from blocked sender' 'MessageReceiver.decryptEnvelope: Dropping non-GV2 message from blocked sender'
); );
return { plaintext: undefined, envelope }; return { plaintext: undefined, envelope };
} }

View file

@ -2244,7 +2244,7 @@ export default class MessageSender {
} }
async getGroupFromLink( async getGroupFromLink(
groupInviteLink: string, groupInviteLink: string | undefined,
auth: Readonly<GroupCredentialsType> auth: Readonly<GroupCredentialsType>
): Promise<Proto.GroupJoinInfo> { ): Promise<Proto.GroupJoinInfo> {
return this.server.getGroupFromLink(groupInviteLink, auth); return this.server.getGroupFromLink(groupInviteLink, auth);

View file

@ -542,7 +542,7 @@ const URL_CALLS = {
groupLog: 'v1/groups/logs', groupLog: 'v1/groups/logs',
groupJoinedAtVersion: 'v1/groups/joined_at_version', groupJoinedAtVersion: 'v1/groups/joined_at_version',
groups: 'v1/groups', groups: 'v1/groups',
groupsViaLink: 'v1/groups/join', groupsViaLink: 'v1/groups/join/',
groupToken: 'v1/groups/token', groupToken: 'v1/groups/token',
keys: 'v2/keys', keys: 'v2/keys',
messages: 'v1/messages', messages: 'v1/messages',
@ -830,7 +830,7 @@ export type WebAPIType = {
getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>; getHasSubscription: (subscriberId: Uint8Array) => Promise<boolean>;
getGroup: (options: GroupCredentialsType) => Promise<Proto.Group>; getGroup: (options: GroupCredentialsType) => Promise<Proto.Group>;
getGroupFromLink: ( getGroupFromLink: (
inviteLinkPassword: string, inviteLinkPassword: string | undefined,
auth: GroupCredentialsType auth: GroupCredentialsType
) => Promise<Proto.GroupJoinInfo>; ) => Promise<Proto.GroupJoinInfo>;
getGroupAvatar: (key: string) => Promise<Uint8Array>; getGroupAvatar: (key: string) => Promise<Uint8Array>;
@ -2595,14 +2595,16 @@ export function initialize({
} }
async function getGroupFromLink( async function getGroupFromLink(
inviteLinkPassword: string, inviteLinkPassword: string | undefined,
auth: GroupCredentialsType auth: GroupCredentialsType
): Promise<Proto.GroupJoinInfo> { ): Promise<Proto.GroupJoinInfo> {
const basicAuth = generateGroupAuth( const basicAuth = generateGroupAuth(
auth.groupPublicParamsHex, auth.groupPublicParamsHex,
auth.authCredentialPresentationHex auth.authCredentialPresentationHex
); );
const safeInviteLinkPassword = toWebSafeBase64(inviteLinkPassword); const safeInviteLinkPassword = inviteLinkPassword
? toWebSafeBase64(inviteLinkPassword)
: undefined;
const response = await _ajax({ const response = await _ajax({
basicAuth, basicAuth,
@ -2611,7 +2613,9 @@ export function initialize({
host: storageUrl, host: storageUrl,
httpType: 'GET', httpType: 'GET',
responseType: 'bytes', responseType: 'bytes',
urlParameters: `/${safeInviteLinkPassword}`, urlParameters: safeInviteLinkPassword
? `${safeInviteLinkPassword}`
: undefined,
redactUrl: _createRedactor(safeInviteLinkPassword), redactUrl: _createRedactor(safeInviteLinkPassword),
}); });

1
ts/window.d.ts vendored
View file

@ -473,6 +473,7 @@ declare global {
GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean; GV2_ENABLE_SINGLE_CHANGE_PROCESSING: boolean;
GV2_ENABLE_CHANGE_PROCESSING: boolean; GV2_ENABLE_CHANGE_PROCESSING: boolean;
GV2_ENABLE_STATE_PROCESSING: boolean; GV2_ENABLE_STATE_PROCESSING: boolean;
GV2_ENABLE_PRE_JOIN_FETCH: boolean;
GV2_MIGRATION_DISABLE_ADD: boolean; GV2_MIGRATION_DISABLE_ADD: boolean;
GV2_MIGRATION_DISABLE_INVITE: boolean; GV2_MIGRATION_DISABLE_INVITE: boolean;