Support for new GroupV2 groups

This commit is contained in:
Scott Nonnenberg 2020-09-08 19:25:05 -07:00
parent 1ce0959fa1
commit 7a02cc815d
53 changed files with 7326 additions and 839 deletions

1
.gitignore vendored
View file

@ -13,6 +13,7 @@ release/
.nyc_output/
*.sublime*
/sql/
/start.sh
# generated files
js/components.js

View file

@ -18,6 +18,9 @@ example
coverage
.nyc_output
# unneeded files
*.js.map
# build scripts
Makefile
Gulpfile.js

View file

@ -272,7 +272,7 @@
"description": "Used as a label on a button allowing user to see more information"
},
"youLeftTheGroup": {
"message": "You left the group.",
"message": "You are no longer a member of the group.",
"description": "Displayed when a user can't send a message because they have left the group"
},
"scrollDown": {
@ -1381,6 +1381,16 @@
}
}
},
"timerSetByMember": {
"message": "A member set the disappearing message time to $time$.",
"description": "Message displayed when timer is by an unknown group member.",
"placeholders": {
"time": {
"content": "$1",
"example": "10m"
}
}
},
"theyChangedTheTimer": {
"message": "$name$ set the disappearing message time to $time$.",
"description": "Message displayed when someone else changes the message expiration timer in a conversation.",
@ -1499,6 +1509,10 @@
"message": "Disappearing messages disabled",
"description": "Displayed in the left pane when the timer is turned off"
},
"disappearingMessagesDisabledByMember": {
"message": "A member disabled disappearing messages.",
"description": "Displayed in the left pane when the timer is turned off"
},
"disabledDisappearingMessages": {
"message": "$name$ disabled disappearing messages.",
"description": "Displayed in the conversation list when the timer is turned off",
@ -2829,5 +2843,730 @@
"EmojiButton__label": {
"message": "Emoji",
"description": "Label for emoji button"
},
"GroupV2--admin": {
"message": "Admin",
"description": "Shown next to the set of administrators in a group"
},
"GroupV2--timerConflict": {
"message": "Failed to update disappearing message timer. Please try again later.",
"description": "Shown if the user runs into a group update conflict attempting to update a GroupV2 message timer"
},
"GroupV2--title--change--other": {
"message": "$memberName$ changed the group name to \"$newTitle$\".",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
},
"newTitle": {
"content": "$2",
"example": "Saturday Hiking"
}
}
},
"GroupV2--title--change--you": {
"message": "You changed the group name to \"$newTitle$\".",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"newTitle": {
"content": "$1",
"example": "Saturday Hiking"
}
}
},
"GroupV2--title--change--unknown": {
"message": "A member changed the group name to \"$newTitle$\".",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"newTitle": {
"content": "$1",
"example": "Saturday Hiking"
}
}
},
"GroupV2--title--remove--other": {
"message": "$memberName$ removed the group name.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--title--remove--you": {
"message": "You removed the group name.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--title--remove--unknown": {
"message": "A member removed the group name.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--avatar--change--other": {
"message": "$memberName$ changed the group avatar.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--avatar--change--you": {
"message": "You changed the group avatar.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--avatar--change--unknown": {
"message": "A member changed the group avatar.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--avatar--remove--other": {
"message": "$memberName$ removed the group avatar.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--avatar--remove--you": {
"message": "You removed the group avatar.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--avatar--remove--unknown": {
"message": "A member removed the group avatar.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-attributes--admins--other": {
"message": "$adminName$ changed who can edit group info to \"Only admins.\"",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--access-attributes--admins--you": {
"message": "You changed who can edit group info to \"Only admins.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-attributes--admins--unknown": {
"message": "An admin changed who can edit group info to \"Only admins.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-attributes--all--other": {
"message": "$adminName$ changed who can edit group info to \"All members.\"",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--access-attributes--all--you": {
"message": "You changed who can edit group info to \"All members.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-attributes--all--unknown": {
"message": "An admin changed who can edit group info to \"All members.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-members--admins--other": {
"message": "$adminName$ changed who can edit group membership to \"Only admins.\"",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--access-members--admins--you": {
"message": "You changed who can edit group membership to \"Only admins.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-members--admins--unknown": {
"message": "An admin changed who can edit group membership to \"Only admins.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-members--all--other": {
"message": "$adminName$ changed who can edit group membership to \"All members.\"",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--access-members--all--you": {
"message": "You changed who can edit group membership to \"All members.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--access-members--all--unknown": {
"message": "An admin changed who can edit group membership to \"All members.\"",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--member-add--from-invite--other": {
"message": "$inviteeName$ accepted an invitation to the group from $inviterName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Alice"
},
"inviterName": {
"content": "$2",
"example": "Bob"
}
}
},
"GroupV2--member-add--from-invite--you": {
"message": "You accepted an invitation to the group from $inviterName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviterName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-add--from-invite--from-you": {
"message": "$inviteeName$ accepted your invitation to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-add--other--other": {
"message": "$adderName$ added $addeeName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adderName": {
"content": "$1",
"example": "Bob"
},
"addeeName": {
"content": "$2",
"example": "Alice"
}
}
},
"GroupV2--member-add--other--you": {
"message": "You added $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-add--other--unknown": {
"message": "A member added $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-add--you--other": {
"message": "$memberName$ added you to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-add--you--you": {
"message": "You joined the group.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--member-add--you--unknown": {
"message": "A member added you to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--member-remove--other--other": {
"message": "$adminName$ removed $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
},
"memberName": {
"content": "$2",
"example": "Alice"
}
}
},
"GroupV2--member-remove--other--self": {
"message": "$memberName$ left.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-remove--other--you": {
"message": "You removed $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-remove--other--unknown": {
"message": "A member removed $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-remove--you--other": {
"message": "$adminName$ removed you.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-remove--you--you": {
"message": "You left.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--member-remove--you--unknown": {
"message": "A member removed you.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--member-privilege--promote--other--other": {
"message": "$adminName$ made $memberName$ an admin.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
},
"memberName": {
"content": "$2",
"example": "Alice"
}
}
},
"GroupV2--member-privilege--promote--other--you": {
"message": "You made $memberName$ an admin.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-privilege--promote--other--unknown": {
"message": "An admin made $memberName$ an admin.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-privilege--promote--you--other": {
"message": "$adminName$ made you an admin.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-privilege--promote--you--unknown": {
"message": "An admin made you an admin.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--member-privilege--demote--other--other": {
"message": "$adminName$ revoked admin privileges from $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
},
"memberName": {
"content": "$2",
"example": "Alice"
}
}
},
"GroupV2--member-privilege--demote--other--you": {
"message": "You revoked admin privileges from $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-privilege--demote--other--unknown": {
"message": "An admin revoked admin privileges from $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"memberName": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-privilege--demote--you--other": {
"message": "$adminName$ revoked your admin privileges.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--member-privilege--demote--you--unknown": {
"message": "An admin revoked your admin privileges.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--pending-add--one--other--other": {
"message": "$memberName$ invited 1 person to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-add--one--other--you": {
"message": "You invited $inviteeName$ to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-add--one--other--unknown": {
"message": "A member invited 1 person to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-add--one--you--other": {
"message": "$memberName$ invited you to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-add--one--you--unknown": {
"message": "A member invited you to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--pending-add--many--other": {
"message": "$memberName$ invited $count$ people to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
},
"count": {
"content": "$2",
"example": "5"
}
}
},
"GroupV2--pending-add--many--you": {
"message": "You invited $count$ people to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"GroupV2--pending-add--many--unknown": {
"message": "A member invited $count$ people to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"GroupV2--pending-remove--decline--other": {
"message": "1 person invited by $memberName$ declined the invitation to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--decline--you": {
"message": "$inviteeName$ declined your invitation to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--decline--unknown": {
"message": "1 person declined their invitation to the group.",
"description": "Shown in timeline or conversation preview when v2 group changes"
},
"GroupV2--pending-remove--revoke--one--other": {
"message": "$memberName$ revoked an invitation to the group for 1 person.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke--one--you": {
"message": "You revoked an invitation to the group for 1 person.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke--one--unknown": {
"message": "An admin revoked an invitation to the group for 1 person.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke--many--other": {
"message": "$memberName$ revoked invitations to the group for $count$ people.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
},
"count": {
"content": "$2",
"example": "5"
}
}
},
"GroupV2--pending-remove--revoke--many--you": {
"message": "You revoked invitations to the group for $count$ people.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"GroupV2--pending-remove--revoke--many--unknown": {
"message": "An admin revoked invitations to the group for $count$ people.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"GroupV2--pending-remove--revoke-invite-from--one--other": {
"message": "$adminName$ revoked an invitation to the group for 1 person invited by $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
},
"memberName": {
"content": "$2",
"example": "Alice"
}
}
},
"GroupV2--pending-remove--revoke-invite-from--one--you": {
"message": "You revoked an invitation to the group for 1 person invited by $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-invite-from--one--unknown": {
"message": "An admin revoked an invitation to the group for 1 person invited by $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"memberName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-invite-from-you--one--other": {
"message": "$adminName$ revoked the invitation to the group you sent to $inviteeName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-invite-from-you--one--you": {
"message": "You rescinded your invitation to $inviteeName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-invite-from-you--one--unknown": {
"message": "An admin revoked the invitation to the group you sent to $inviteeName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"inviteeName": {
"content": "$1",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-invite-from--many--other": {
"message": "$adminName$ revoked invitations to the group for $count$ people invited by $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
},
"memberName": {
"content": "$2",
"example": "Alice"
}
}
},
"GroupV2--pending-remove--revoke-invite-from--many--you": {
"message": "You revoked invitations to the group for $count$ people invited by $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
},
"memberName": {
"content": "$2",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-invite-from--many--unknown": {
"message": "An admin revoked invitations to the group for $count$ people invited by $memberName$.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
},
"memberName": {
"content": "$2",
"example": "Bob"
}
}
},
"GroupV2--pending-remove--revoke-invite-from-you--many--other": {
"message": "$adminName$ revoked the invitations to the group you sent to $count$ people.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"adminName": {
"content": "$1",
"example": "Bob"
},
"count": {
"content": "$2",
"example": "5"
}
}
},
"GroupV2--pending-remove--revoke-invite-from-you--many--you": {
"message": "You rescinded your invitation to $count$ people.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
},
"GroupV2--pending-remove--revoke-invite-from-you--many--unknown": {
"message": "An admin revoked the invitations to the group you sent to $count$ people.",
"description": "Shown in timeline or conversation preview when v2 group changes",
"placeholders": {
"count": {
"content": "$1",
"example": "5"
}
}
}
}

View file

@ -546,7 +546,7 @@
}
if (
window.isBeforeVersion(lastVersion, 'v1.35.0-beta.11') &&
window.isBeforeVersion(lastVersion, 'v1.36.0-beta.1') &&
window.isAfterVersion(lastVersion, 'v1.35.0-beta.1')
) {
await window.Signal.Services.eraseAllStorageServiceState();
@ -606,6 +606,17 @@
// flags are represented in the cached props we generate on load of each convo.
window.Signal.RemoteConfig.initRemoteConfig();
// On startup, we don't want to wait for the remote config fetch if we've already
// learned that this instance supports GroupsV2.
// This is how we keep it sticky. Once it is enabled, we never disable it.
if (
window.Signal.RemoteConfig.isEnabled('desktop.gv2') ||
window.storage.get('gv2-enabled')
) {
window.GV2 = true;
window.storage.put('gv2-enabled', true);
}
try {
await Promise.all([
ConversationController.load(),
@ -1552,6 +1563,33 @@
removeMessageRequestListener();
}
);
// Listen for changes to the `desktop.gv2` remote configuration flag
const removeGv2Listener = window.Signal.RemoteConfig.onChange(
'desktop.gv2',
async ({ enabled }) => {
if (!enabled) {
return;
}
window.GV2 = true;
await window.storage.put('gv2-enabled', true);
window.Signal.Services.handleUnknownRecords(
window.textsecure.protobuf.ManifestRecord.Identifier.Type.GROUPV2
);
// Erase current manifest version so we re-process storage service data
await window.storage.remove('manifestVersion');
// Kick off storage service fetch to grab GroupV2 information
await window.Signal.Services.runStorageServiceSyncJob();
// This is a one-time thing
removeGv2Listener();
}
);
}
window.getSyncRequest = () =>
@ -1657,45 +1695,58 @@
PASSWORD
);
try {
if (connectCount === 0) {
const lonelyE164s = window
.getConversations()
.filter(
c =>
c.isPrivate() &&
c.get('e164') &&
!c.get('uuid') &&
!c.isEverUnregistered()
)
.map(c => c.get('e164'));
if (lonelyE164s.length > 0) {
const lookup = await textsecure.messaging.getUuidsForE164s(
lonelyE164s
);
const e164s = Object.keys(lookup);
e164s.forEach(e164 => {
const uuid = lookup[e164];
if (!uuid) {
const byE164 = window.ConversationController.get(e164);
if (byE164) {
byE164.setUnregistered();
}
}
window.ConversationController.ensureContactIds({
e164,
uuid,
highTrust: true,
});
});
}
if (connectCount === 0) {
try {
// Force a re-fetch before we process our queue. We may want to turn on something
// which changes how we process incoming messages!
await window.Signal.RemoteConfig.refreshRemoteConfig();
} catch (error) {
window.log.error(
'connect: Error refreshing remote config:',
error && error.stack ? error.stack : error
);
}
try {
if (window.Signal.RemoteConfig.isEnabled('desktop.cds')) {
const lonelyE164s = window
.getConversations()
.filter(
c =>
c.isPrivate() &&
c.get('e164') &&
!c.get('uuid') &&
!c.isEverUnregistered()
)
.map(c => c.get('e164'));
if (lonelyE164s.length > 0) {
const lookup = await textsecure.messaging.getUuidsForE164s(
lonelyE164s
);
const e164s = Object.keys(lookup);
e164s.forEach(e164 => {
const uuid = lookup[e164];
if (!uuid) {
const byE164 = window.ConversationController.get(e164);
if (byE164) {
byE164.setUnregistered();
}
}
window.ConversationController.ensureContactIds({
e164,
uuid,
highTrust: true,
});
});
}
}
} catch (error) {
window.log.error(
'connect: Error fetching UUIDs for lonely e164s:',
error && error.stack ? error.stack : error
);
}
} catch (error) {
window.log.error(
'Error fetching UUIDs for lonely e164s:',
error && error.stack ? error.stack : error
);
}
connectCount += 1;
@ -1718,6 +1769,8 @@
);
window.textsecure.messageReceiver = messageReceiver;
window.Signal.Services.initializeGroupCredentialFetcher();
preMessageReceiverStatus = null;
function addQueuedEventListener(name, handler) {
@ -1810,26 +1863,25 @@
}
}
// TODO: uncomment this once we want to start registering UUID support
// const hasRegisteredUuidSupportKey = 'hasRegisteredUuidSupport';
// if (
// !storage.get(hasRegisteredUuidSupportKey) &&
// textsecure.storage.user.getUuid()
// ) {
// const server = WebAPI.connect({
// username: USERNAME || OLD_USERNAME,
// password: PASSWORD,
// });
// try {
// await server.registerCapabilities({ uuid: true });
// storage.put(hasRegisteredUuidSupportKey, true);
// } catch (error) {
// window.log.error(
// 'Error: Unable to register support for UUID messages.',
// error && error.stack ? error.stack : error
// );
// }
// }
const hasRegisteredGroupV2SupportKey = 'hasRegisteredGroupV2Support';
if (
!storage.get(hasRegisteredGroupV2SupportKey) &&
textsecure.storage.user.getUuid()
) {
const server = WebAPI.connect({
username: USERNAME || OLD_USERNAME,
password: PASSWORD,
});
try {
await server.registerCapabilities({ gv2: true });
storage.put(hasRegisteredGroupV2SupportKey, true);
} catch (error) {
window.log.error(
'Error: Unable to register support for GroupV2.',
error && error.stack ? error.stack : error
);
}
}
const deviceId = textsecure.storage.user.getDeviceId();
@ -1918,6 +1970,49 @@
view.applyTheme();
}
}
const FIVE_MINUTES = 5 * 60 * 1000;
// Note: once this function returns, there still might be messages being processed on
// a given conversation's queue. But we have processed all events from the websocket.
async function waitForEmptyEventQueue() {
if (!messageReceiver) {
window.log.info(
'waitForEmptyEventQueue: No messageReceiver available, returning early'
);
return;
}
if (!messageReceiver.hasEmptied()) {
window.log.info(
'waitForEmptyEventQueue: Waiting for MessageReceiver empty event...'
);
let resolve;
let reject;
const promise = new Promise((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
const timeout = setTimeout(reject, FIVE_MINUTES);
const onEmptyOnce = () => {
messageReceiver.removeEventListener('empty', onEmptyOnce);
clearTimeout(timeout);
resolve();
};
messageReceiver.addEventListener('empty', onEmptyOnce);
await promise;
}
window.log.info(
'waitForEmptyEventQueue: Waiting for event handler queue idle...'
);
await eventHandlerQueue.onIdle();
}
window.waitForEmptyEventQueue = waitForEmptyEventQueue;
async function onEmpty() {
await Promise.all([
window.waitForAllBatchers(),
@ -1937,11 +2032,6 @@
logger: window.log,
});
// Force a re-fetch here when we've processed our queue. Without this, we won't try
// again for two hours after our first attempt. Which might have been while we were
// offline or didn't have credentials.
window.Signal.RemoteConfig.refreshRemoteConfig();
let interval = setInterval(() => {
const view = window.owsDesktopApp.appView;
if (view) {
@ -2024,7 +2114,7 @@
// Note: this type of message is automatically removed from cache in MessageReceiver
const { typing, sender, senderUuid, senderDevice } = ev;
const { groupId, started } = typing || {};
const { groupId, groupV2Id, started } = typing || {};
// We don't do anything with incoming typing messages if the setting is disabled
if (!storage.get('typingIndicators')) {
@ -2036,27 +2126,34 @@
uuid: senderUuid,
highTrust: true,
});
const conversation = ConversationController.get(groupId || senderId);
const conversation = ConversationController.get(
groupV2Id || groupId || senderId
);
const ourId = ConversationController.getOurConversationId();
if (conversation) {
// We drop typing notifications in groups we're not a part of
if (!conversation.isPrivate() && !conversation.hasMember(ourId)) {
window.log.warn(
`Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
);
return;
}
conversation.notifyTyping({
isTyping: started,
isMe: ourId === senderId,
sender,
senderUuid,
senderId,
senderDevice,
});
if (!conversation) {
window.log.warn(
`onTyping: Did not find conversation for typing indicator (groupv2(${groupV2Id}), group(${groupId}), ${sender}, ${senderUuid})`
);
return;
}
// We drop typing notifications in groups we're not a part of
if (!conversation.isPrivate() && !conversation.hasMember(ourId)) {
window.log.warn(
`Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
);
return;
}
conversation.notifyTyping({
isTyping: started,
isMe: ourId === senderId,
sender,
senderUuid,
senderId,
senderDevice,
});
}
async function onStickerPack(ev) {
@ -2227,6 +2324,7 @@
}
}
// Note: this handler is only for v1 groups received via 'group sync' messages
async function onGroupReceived(ev) {
const details = ev.groupDetails;
const { id } = details;
@ -2244,6 +2342,13 @@
id,
'group'
);
if (conversation.get('groupVersion') > 1) {
window.log.warn(
'Got group sync for v2 group: ',
conversation.idForLoggoing()
);
return;
}
const memberConversations = details.membersE164.map(e164 =>
ConversationController.getOrCreate(e164, 'private')
@ -2321,37 +2426,6 @@
);
}
// Descriptors
const getGroupDescriptor = group => ({
type: Message.GROUP,
id: group.id,
});
// Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`:
const getDescriptorForSent = ({ message, destination, destinationUuid }) =>
message.group
? getGroupDescriptor(message.group)
: {
type: Message.PRIVATE,
id: ConversationController.ensureContactIds({
e164: destination,
uuid: destinationUuid,
}),
};
// Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`:
const getDescriptorForReceived = ({ message, source, sourceUuid }) =>
message.group
? getGroupDescriptor(message.group)
: {
type: Message.PRIVATE,
id: ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
}),
};
// Received:
async function handleMessageReceivedProfileUpdate({
data,
@ -2369,6 +2443,50 @@
return confirm();
}
// Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`:
const getDescriptorForReceived = ({ message, source, sourceUuid }) => {
if (message.groupV2) {
const { id } = message.groupV2;
const conversationId = ConversationController.ensureGroup(id, {
groupVersion: 2,
masterKey: message.groupV2.masterKey,
secretParams: message.groupV2.secretParams,
publicParams: message.groupV2.publicParams,
});
return {
type: Message.GROUP,
id: conversationId,
};
}
if (message.group) {
const { id } = message.group;
const fromContactId = ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
});
const conversationId = ConversationController.ensureGroup(id, {
addedBy: fromContactId,
});
return {
type: Message.GROUP,
id: conversationId,
};
}
return {
type: Message.PRIVATE,
id: ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
highTrust: true,
}),
};
};
// Note: We do very little in this function, since everything in handleDataMessage is
// inside a conversation-specific queue(). Any code here might run before an earlier
// message is processed in handleDataMessage().
@ -2392,13 +2510,16 @@
if (data.message.reaction) {
const { reaction } = data.message;
window.log.info('Queuing reaction for', reaction.targetTimestamp);
window.log.info(
'Queuing incoming reaction for',
reaction.targetTimestamp
);
const reactionModel = Whisper.Reactions.add({
emoji: reaction.emoji,
remove: reaction.remove,
targetAuthorE164: reaction.targetAuthorE164,
targetAuthorUuid: reaction.targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp.toNumber(),
targetTimestamp: reaction.targetTimestamp,
timestamp: Date.now(),
fromId: ConversationController.ensureContactIds({
e164: data.source,
@ -2413,7 +2534,7 @@
if (data.message.delete) {
const { delete: del } = data.message;
window.log.info('Queuing DOE for', del.targetSentTimestamp);
window.log.info('Queuing incoming DOE for', del.targetSentTimestamp);
const deleteModel = Whisper.Deletes.add({
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: data.serverTimestamp,
@ -2508,11 +2629,6 @@
data.unidentifiedDeliveries = unidentified.map(item => item.destination);
}
const isGroup = descriptor.type === Message.GROUP;
const conversationId = isGroup
? ConversationController.ensureGroup(descriptor.id)
: descriptor.id;
return new Whisper.Message({
source: textsecure.storage.user.getNumber(),
sourceUuid: textsecure.storage.user.getUuid(),
@ -2521,7 +2637,7 @@
serverTimestamp: data.serverTimestamp,
sent_to: sentTo,
received_at: now,
conversationId,
conversationId: descriptor.id,
type: 'outgoing',
sent: true,
unidentifiedDeliveries: data.unidentifiedDeliveries || [],
@ -2532,6 +2648,42 @@
});
}
// Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`:
const getDescriptorForSent = ({ message, destination, destinationUuid }) => {
if (message.groupV2) {
const { id } = message.groupV2;
const conversationId = ConversationController.ensureGroup(id, {
groupVersion: 2,
masterKey: message.groupV2.masterKey,
secretParams: message.groupV2.secretParams,
publicParams: message.groupV2.publicParams,
});
return {
type: Message.GROUP,
id: conversationId,
};
}
if (message.group) {
const { id } = message.group;
const conversationId = ConversationController.ensureGroup(id);
return {
type: Message.GROUP,
id: conversationId,
};
}
return {
type: Message.PRIVATE,
id: ConversationController.ensureContactIds({
e164: destination,
uuid: destinationUuid,
highTrust: true,
}),
};
};
// Note: We do very little in this function, since everything in handleDataMessage is
// inside a conversation-specific queue(). Any code here might run before an earlier
// message is processed in handleDataMessage().
@ -2555,12 +2707,13 @@
if (data.message.reaction) {
const { reaction } = data.message;
window.log.info('Queuing sent reaction for', reaction.targetTimestamp);
const reactionModel = Whisper.Reactions.add({
emoji: reaction.emoji,
remove: reaction.remove,
targetAuthorE164: reaction.targetAuthorE164,
targetAuthorUuid: reaction.targetAuthorUuid,
targetTimestamp: reaction.targetTimestamp.toNumber(),
targetTimestamp: reaction.targetTimestamp,
timestamp: Date.now(),
fromId: ConversationController.getOurConversationId(),
fromSync: true,
@ -2574,6 +2727,7 @@
if (data.message.delete) {
const { delete: del } = data.message;
window.log.info('Queuing sent DOE for', del.targetSentTimestamp);
const deleteModel = Whisper.Deletes.add({
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: del.serverTimestamp,
@ -2594,20 +2748,6 @@
}
function initIncomingMessage(data, descriptor) {
// Ensure that we have an accurate record for who this message is from
const fromContactId = ConversationController.ensureContactIds({
e164: data.source,
uuid: data.sourceUuid,
highTrust: true,
});
const isGroup = descriptor.type === Message.GROUP;
const conversationId = isGroup
? ConversationController.ensureGroup(descriptor.id, {
addedBy: fromContactId,
})
: fromContactId;
return new Whisper.Message({
source: data.source,
sourceUuid: data.sourceUuid,
@ -2615,7 +2755,7 @@
sent_at: data.timestamp,
serverTimestamp: data.serverTimestamp,
received_at: Date.now(),
conversationId,
conversationId: descriptor.id,
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
type: 'incoming',
unread: 1,
@ -2729,6 +2869,14 @@
const conversationId = message.get('conversationId');
const conversation = ConversationController.get(conversationId);
if (!conversation) {
window.log.warn(
'onError: No conversation id, cannot save error bubble'
);
ev.confirm();
return Promise.resolve();
}
// This matches the queueing behavior used in Message.handleDataMessage
conversation.queueJob(async () => {
const existingMessage = await window.Signal.Data.getMessageBySender(

View file

@ -18,7 +18,7 @@
if (conversation.isPrivate()) {
recipients = [conversation.id];
} else {
recipients = conversation.get('members') || [];
recipients = conversation.getMemberIds();
}
const receipts = this.filter(
receipt =>

View file

@ -65,7 +65,7 @@
return;
}
window.log.info(`adding groupId(${groupId}) to blocked list`);
window.log.info(`adding group(${groupId}) to blocked list`);
storage.put(BLOCKED_GROUPS_ID, groupIds.concat(groupId));
};
storage.removeBlockedGroup = groupId => {

View file

@ -78,6 +78,9 @@
const e164 = this.get('e164');
return `${uuid || e164} (${this.id})`;
}
if (this.get('groupVersion') > 1) {
return `groupv2(${this.get('groupId')})`;
}
const groupId = this.get('groupId');
return `group(${groupId})`;
@ -403,24 +406,83 @@
}
},
async fetchLatestGroupV2Data() {
if (this.get('groupVersion') !== 2) {
return;
}
await window.Signal.Groups.waitThenMaybeUpdateGroup({
conversation: this,
});
},
maybeRepairGroupV2(data) {
if (
this.get('groupVersion') &&
this.get('masterKey') &&
this.get('secretParams') &&
this.get('publicParams')
) {
return;
}
window.log.info(`Repairing GroupV2 conversation ${this.idForLogging()}`);
const { masterKey, secretParams, publicParams } = data;
this.set({ masterKey, secretParams, publicParams, groupVersion: 2 });
window.Signal.Data.updateConversation(this.attributes);
},
getGroupV2Info(groupChange) {
if (this.isPrivate() || this.get('groupVersion') !== 2) {
return null;
}
return {
masterKey: window.Signal.Crypto.base64ToArrayBuffer(
this.get('masterKey')
),
revision: this.get('revision'),
members: this.getRecipients(),
groupChange,
};
},
getGroupV1Info() {
if (this.isPrivate() || this.get('groupVersion') > 0) {
return null;
}
return {
id: this.get('groupId'),
members: this.getRecipients(),
};
},
sendTypingMessage(isTyping) {
if (!textsecure.messaging) {
return;
}
const groupId = !this.isPrivate() ? this.get('groupId') : null;
const groupNumbers = this.getRecipients();
// We don't send typing messages to our other devices
if (this.isMe()) {
return;
}
const recipientId = this.isPrivate() ? this.getSendTarget() : null;
const groupId = !this.isPrivate() ? this.get('groupId') : null;
const groupMembers = this.getRecipients();
// We don't send typing messages if our recipients list is empty
if (!this.isPrivate() && !groupMembers.length) {
return;
}
const sendOptions = this.getSendOptions();
this.wrapSend(
textsecure.messaging.sendTypingMessage(
{
isTyping,
recipientId,
groupId,
groupNumbers,
groupMembers,
},
sendOptions
)
@ -581,7 +643,7 @@
lastUpdated: this.get('timestamp'),
membersCount: this.isPrivate()
? undefined
: (this.get('members') || []).length,
: (this.get('membersV2') || this.get('members') || []).length,
messageRequestsEnabled,
muteExpiresAt: this.get('muteExpiresAt'),
name: this.get('name'),
@ -793,7 +855,8 @@
return;
}
await this.fetchContacts();
this.fetchContacts();
await Promise.all(
this.contactCollection.map(async contact => {
if (!contact.isMe()) {
@ -1324,26 +1387,59 @@
return this.jobQueue.add(taskWithTimeout);
},
getRecipients() {
getMembers() {
if (this.isPrivate()) {
return [this.getSendTarget()];
return [this];
}
const me = ConversationController.getOurConversationId();
// The list of members might not always be conversationIds for old groups.
if (this.get('membersV2')) {
return _.compact(
this.get('membersV2').map(member => {
const c = ConversationController.get(member.conversationId);
// In groups we won't sent to contacts we believe are unregistered
if (c && c.isUnregistered()) {
return null;
}
return c;
})
);
}
if (this.get('members')) {
return _.compact(
this.get('members').map(id => {
const c = ConversationController.get(id);
// In groups we won't sent to contacts we believe are unregistered
if (c && c.isUnregistered()) {
return null;
}
return c;
})
);
}
window.log.warn(
'getMembers: Group conversation had neither membersV2 nor members'
);
return [];
},
getMemberIds() {
const members = this.getMembers();
return members.map(member => member.id);
},
getRecipients() {
const members = this.getMembers();
// Eliminate our
return _.compact(
this.get('members').map(memberId => {
const c = ConversationController.get(memberId);
if (c.id === me) {
return null;
}
// We don't want to even attempt a send if we have recently discovered that they
// are unregistered.
if (c.isUnregistered()) {
return null;
}
return c.getSendTarget();
})
members.map(member => (member.isMe() ? null : member.getSendTarget()))
);
},
@ -1549,11 +1645,11 @@
if (this.isMe()) {
const dataMessage = await textsecure.messaging.getMessageProto(
destination,
null,
null,
null,
null,
null,
null, // body
null, // attachments
null, // quote
null, // preview
null, // sticker
outgoingReaction,
timestamp,
expireTimer,
@ -1568,11 +1664,11 @@
if (this.isPrivate()) {
return textsecure.messaging.sendMessageToIdentifier(
destination,
null,
null,
null,
null,
null,
null, // body
null, // attachments
null, // quote
null, // preview
null, // sticker
outgoingReaction,
timestamp,
expireTimer,
@ -1582,17 +1678,14 @@
}
return textsecure.messaging.sendMessageToGroup(
this.get('groupId'),
this.getRecipients(),
null,
null,
null,
null,
null,
outgoingReaction,
timestamp,
expireTimer,
profileKey,
{
groupV1: this.getGroupV1Info(),
groupV2: this.getGroupV2Info(),
reaction: outgoingReaction,
timestamp,
expireTimer,
profileKey,
},
options
);
})();
@ -1741,7 +1834,7 @@
quote,
preview,
sticker,
null,
null, // reaction
now,
expireTimer,
profileKey
@ -1752,43 +1845,38 @@
const conversationType = this.get('type');
const options = this.getSendOptions();
const promise = (() => {
switch (conversationType) {
case Message.PRIVATE:
return textsecure.messaging.sendMessageToIdentifier(
destination,
messageBody,
finalAttachments,
quote,
preview,
sticker,
null,
now,
expireTimer,
profileKey,
options
);
case Message.GROUP:
return textsecure.messaging.sendMessageToGroup(
this.get('groupId'),
this.getRecipients(),
messageBody,
finalAttachments,
quote,
preview,
sticker,
null,
now,
expireTimer,
profileKey,
options
);
default:
throw new TypeError(
`Invalid conversation type: '${conversationType}'`
);
}
})();
let promise;
if (conversationType === Message.GROUP) {
promise = textsecure.messaging.sendMessageToGroup(
{
attachments: finalAttachments,
expireTimer,
groupV1: this.getGroupV1Info(),
groupV2: this.getGroupV2Info(),
messageText: messageBody,
preview,
profileKey,
quote,
sticker,
timestamp: now,
},
options
);
} else {
promise = textsecure.messaging.sendMessageToIdentifier(
destination,
messageBody,
finalAttachments,
quote,
preview,
sticker,
null, // reaction
now,
expireTimer,
profileKey,
options
);
}
return message.send(this.wrapSend(promise));
});
@ -2012,7 +2100,9 @@
const currentTimestamp = this.get('timestamp') || null;
const timestamp = activityMessage
? activityMessage.get('sent_at') || currentTimestamp
? activityMessage.get('sent_at') ||
activityMessage.get('received_at') ||
currentTimestamp
: currentTimestamp;
this.set({
@ -2043,12 +2133,90 @@
}
},
async updateExpirationTimerInGroupV2(seconds) {
// Make change on the server
const actions = window.Signal.Groups.buildDisappearingMessagesTimerChange(
{
expireTimer: seconds || 0,
group: this.attributes,
}
);
let signedGroupChange;
try {
signedGroupChange = await window.Signal.Groups.uploadGroupChange({
actions,
group: this.attributes,
serverPublicParamsBase64: window.getServerPublicParams(),
});
} catch (error) {
// Get latest GroupV2 data, since we ran into trouble updating it
this.fetchLatestGroupV2Data();
throw error;
}
// Update local conversation
this.set({
expireTimer: seconds || 0,
revision: actions.version,
});
window.Signal.Data.updateConversation(this.attributes);
// Create local notification
const timestamp = Date.now();
const id = window.getGuid();
const message = MessageController.register(
id,
new Whisper.Message({
id,
conversationId: this.id,
sent_at: timestamp,
received_at: timestamp,
flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
expirationTimerUpdate: {
expireTimer: seconds,
sourceUuid: this.ourUuid,
},
})
);
await window.Signal.Data.saveMessage(message.attributes, {
Message: Whisper.Message,
forceSave: true,
});
this.trigger('newmessage', message);
// Send message to all group members
const profileKey = this.get('profileSharing')
? storage.get('profileKey')
: undefined;
const sendOptions = this.getSendOptions();
const promise = textsecure.messaging.sendMessageToGroup(
{
groupV2: this.getGroupV2Info(signedGroupChange.toArrayBuffer()),
timestamp,
profileKey,
},
sendOptions
);
message.send(promise);
},
async updateExpirationTimer(
providedExpireTimer,
providedSource,
receivedAt,
options = {}
) {
if (this.get('groupVersion') === 2) {
if (providedSource || receivedAt) {
throw new Error(
'updateExpirationTimer: GroupV2 timers are not updated this way'
);
}
await this.updateExpirationTimerInGroupV2(providedExpireTimer);
return false;
}
let expireTimer = providedExpireTimer;
let source = providedSource;
if (this.get('left')) {
@ -2131,12 +2299,12 @@
textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
const dataMessage = await textsecure.messaging.getMessageProto(
this.getSendTarget(),
null,
[],
null,
[],
null,
null,
null, // body
[], // attachments
null, // quote
[], // preview
null, // sticker
null, // reaction
message.get('sent_at'),
expireTimer,
profileKey,
@ -2250,79 +2418,6 @@
}
},
async updateGroup(providedGroupUpdate) {
let groupUpdate = providedGroupUpdate;
if (this.isPrivate()) {
throw new Error('Called update group on private conversation');
}
if (groupUpdate === undefined) {
groupUpdate = this.pick(['name', 'avatar', 'members']);
}
const now = Date.now();
const model = new Whisper.Message({
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: now,
group_update: groupUpdate,
});
const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message,
});
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
this.wrapSend(
textsecure.messaging.updateGroup(
this.id,
this.get('name'),
this.get('avatar'),
this.get('members'),
options
)
)
);
},
async leaveGroup() {
const now = Date.now();
if (this.get('type') === 'group') {
const groupIdentifiers = this.getRecipients();
this.set({ left: true });
window.Signal.Data.updateConversation(this.attributes);
const model = new Whisper.Message({
group_update: { left: 'You' },
conversationId: this.id,
type: 'outgoing',
sent_at: now,
received_at: now,
});
const id = await window.Signal.Data.saveMessage(model.attributes, {
Message: Whisper.Message,
});
model.set({ id });
const message = MessageController.register(model.id, model);
this.addSingleMessage(message);
const options = this.getSendOptions();
message.send(
this.wrapSend(
textsecure.messaging.leaveGroup(this.id, groupIdentifiers, options)
)
);
}
},
async markRead(newestUnreadDate, providedOptions) {
const options = providedOptions || {};
_.defaults(options, { sendReadReceipts: true });
@ -2444,14 +2539,7 @@
getProfiles() {
// request all conversation members' keys
let conversations = [];
if (this.isPrivate()) {
conversations = [this];
} else {
conversations = this.get('members')
.map(id => ConversationController.get(id))
.filter(Boolean);
}
const conversations = this.getMembers();
return Promise.all(
_.map(conversations, conversation => {
this.getProfile(conversation.get('uuid'), conversation.get('e164'));
@ -2822,30 +2910,21 @@
},
hasMember(identifier) {
const cid = ConversationController.getConversationId(identifier);
return cid && _.contains(this.get('members'), cid);
const id = ConversationController.getConversationId(identifier);
const memberIds = this.getMemberIds();
return _.contains(memberIds, id);
},
fetchContacts() {
if (this.isPrivate()) {
this.contactCollection.reset([this]);
return Promise.resolve();
}
const members = this.get('members') || [];
const promises = members.map(identifier =>
ConversationController.getOrCreateAndWait(identifier, 'private')
);
return Promise.all(promises).then(contacts => {
_.forEach(contacts, contact => {
this.listenTo(
contact,
'change:verified',
this.onMemberVerifiedChange
);
});
this.contactCollection.reset(contacts);
const members = this.getMembers();
_.forEach(members, member => {
this.listenTo(member, 'change:verified', this.onMemberVerifiedChange);
});
this.contactCollection.reset(members);
},
async destroyMessages() {
@ -2946,6 +3025,43 @@
return null;
},
canChangeTimer() {
if (this.isPrivate()) {
return true;
}
if (this.get('groupVersion') !== 2) {
return true;
}
const accessControlEnum =
textsecure.protobuf.AccessControl.AccessRequired;
const accessControl = this.get('accessControl');
const canAnyoneChangeTimer =
accessControl &&
(accessControl.attributes === accessControlEnum.ANY ||
accessControl.attributes === accessControlEnum.MEMBER);
if (canAnyoneChangeTimer) {
return true;
}
const memberEnum = textsecure.protobuf.Member.Role;
const members = this.get('membersV2') || [];
const myId = ConversationController.getConversationId(
textsecure.storage.user.getUuid() || textsecure.storage.user.getNumber()
);
const me = members.find(item => item.conversationId === myId);
if (!me) {
return false;
}
const isAdministrator = me.role === memberEnum.ADMINISTRATOR;
if (isAdministrator) {
return true;
}
return false;
},
// Set of items to captureChanges on:
// [-] uuid
@ -3184,4 +3300,71 @@
});
Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');
// This is a wrapper model used to display group members in the member list view, within
// the world of backbone, but layering another bit of group-specific data top of base
// conversation data.
Whisper.GroupMemberConversation = Backbone.Model.extend({
initialize(attributes) {
const { conversation, isAdmin } = attributes;
if (!conversation) {
throw new Error(
'GroupMemberConversation.initialze: conversation required!'
);
}
if (!_.isBoolean(isAdmin)) {
throw new Error('GroupMemberConversation.initialze: isAdmin required!');
}
// If our underlying conversation changes, we change too
this.listenTo(conversation, 'change', () => {
this.trigger('change', this);
});
this.conversation = conversation;
this.isAdmin = isAdmin;
},
format() {
return {
...this.conversation.format(),
isAdmin: this.isAdmin,
};
},
get(...params) {
return this.conversation.get(...params);
},
getTitle() {
return this.conversation.getTitle();
},
isMe() {
return this.conversation.isMe();
},
});
// We need a custom collection here to get the sorting we need
Whisper.GroupConversationCollection = Backbone.Collection.extend({
model: Whisper.GroupMemberConversation,
initialize() {
this.collator = new Intl.Collator();
},
comparator(left, right) {
if (left.isAdmin && !right.isAdmin) {
return -1;
}
if (!left.isAdmin && right.isAdmin) {
return 1;
}
const leftLower = left.getTitle().toLowerCase();
const rightLower = right.getTitle().toLowerCase();
return this.collator.compare(leftLower, rightLower);
},
});
})();

View file

@ -45,6 +45,9 @@
const { addStickerPackReference, getMessageBySender } = window.Signal.Data;
const { bytesFromString } = window.Signal.Crypto;
const PLACEHOLDER_CONTACT = {
title: i18n('unknownContact'),
};
window.AccountCache = Object.create(null);
window.AccountJobs = Object.create(null);
@ -140,6 +143,7 @@
!this.isEndSession() &&
!this.isExpirationTimerUpdate() &&
!this.isGroupUpdate() &&
!this.isGroupV2Change() &&
!this.isKeyChange() &&
!this.isMessageHistoryUnsynced() &&
!this.isProfileChange() &&
@ -156,6 +160,12 @@
data: this.getPropsForUnsupportedMessage(),
};
}
if (this.isGroupV2Change()) {
return {
type: 'groupV2Change',
data: this.getPropsForGroupV2Change(),
};
}
if (this.isMessageHistoryUnsynced()) {
return {
type: 'linkNotification',
@ -213,24 +223,13 @@
// Other top-level prop-generation
getPropsForSearchResult() {
const ourId = ConversationController.getOurConversationId();
const sourceId = this.getContactId();
const fromContact = this.findAndFormatContact(sourceId);
if (ourId === sourceId) {
fromContact.isMe = true;
}
const from = this.findAndFormatContact(sourceId);
const convo = this.getConversation();
const to = convo ? this.findAndFormatContact(convo.get('id')) : {};
if (to && convo && convo.isMe()) {
to.isMe = true;
}
const to = this.findAndFormatContact(convo.get('id'));
return {
from: fromContact || {},
from,
to,
isSelected: this.isSelected,
@ -358,6 +357,9 @@
versionAtReceive < requiredVersion
);
},
isGroupV2Change() {
return Boolean(this.get('groupV2Change'));
},
isExpirationTimerUpdate() {
const flag =
textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
@ -399,6 +401,16 @@
contact: this.findAndFormatContact(sourceId),
};
},
getPropsForGroupV2Change() {
const { protobuf } = window.textsecure;
return {
AccessControlEnum: protobuf.AccessControl.AccessRequired,
RoleEnum: protobuf.Member.Role,
ourConversationId: window.ConversationController.getOurConversationId(),
change: this.get('groupV2Change'),
};
},
getPropsForTimerNotification() {
const timerUpdate = this.get('expirationTimerUpdate');
if (!timerUpdate) {
@ -414,9 +426,10 @@
uuid: sourceUuid,
});
const ourId = ConversationController.getOurConversationId();
const formattedContact = this.findAndFormatContact(sourceId);
const basicProps = {
...this.findAndFormatContact(sourceId),
...formattedContact,
type: 'fromOther',
timespan,
disabled,
@ -434,6 +447,12 @@
type: 'fromMe',
};
}
if (!sourceId) {
return {
...basicProps,
type: 'fromMember',
};
}
return basicProps;
},
@ -473,10 +492,6 @@
});
}
const placeholderContact = {
title: i18n('unknownContact'),
};
if (groupUpdate.joined) {
changes.push({
type: 'add',
@ -484,8 +499,7 @@
Array.isArray(groupUpdate.joined)
? groupUpdate.joined
: [groupUpdate.joined],
identifier =>
this.findAndFormatContact(identifier) || placeholderContact
identifier => this.findAndFormatContact(identifier)
),
});
}
@ -502,8 +516,7 @@
Array.isArray(groupUpdate.left)
? groupUpdate.left
: [groupUpdate.left],
identifier =>
this.findAndFormatContact(identifier) || placeholderContact
identifier => this.findAndFormatContact(identifier)
),
});
}
@ -600,15 +613,6 @@
const reactions = (this.get('reactions') || []).map(re => {
const c = this.findAndFormatContact(re.fromId);
if (!c) {
return {
emoji: re.emoji,
from: {
id: re.fromId,
},
};
}
return {
emoji: re.emoji,
timestamp: re.timestamp,
@ -661,17 +665,29 @@
// Dependencies of prop-generation functions
findAndFormatContact(identifier) {
if (!identifier) {
return PLACEHOLDER_CONTACT;
}
const contactModel = this.findContact(identifier);
if (contactModel) {
return contactModel.format();
}
const { format } = PhoneNumber;
const { format, isValidNumber } = PhoneNumber;
const regionCode = storage.get('regionCode');
if (!isValidNumber(identifier, { regionCode })) {
return PLACEHOLDER_CONTACT;
}
const phoneNumber = format(identifier, {
ourRegionCode: regionCode,
});
return {
phoneNumber: format(identifier, {
ourRegionCode: regionCode,
}),
title: phoneNumber,
phoneNumber,
};
},
findContact(identifier) {
@ -910,6 +926,29 @@
};
}
if (this.isGroupV2Change()) {
const { protobuf } = window.textsecure;
const change = this.get('groupV2Change');
const lines = window.Signal.GroupChange.renderChange(change, {
AccessControlEnum: protobuf.AccessControl.AccessRequired,
i18n: window.i18n,
ourConversationId: window.ConversationController.getOurConversationId(),
renderContact: conversationId => {
const conversation = window.ConversationController.get(
conversationId
);
return conversation
? conversation.getTitle()
: window.i18n('unknownUser');
},
renderString: (key, i18n, placeholders) => i18n(key, placeholders),
RoleEnum: protobuf.Member.Role,
});
return { text: lines.join(' ') };
}
const attachments = this.get('attachments') || [];
if (this.isTapToView()) {
@ -1315,6 +1354,7 @@
// Rendered sync messages
const isCallHistory = this.isCallHistory();
const isGroupUpdate = this.isGroupUpdate();
const isGroupV2Change = this.isGroupV2Change();
const isEndSession = this.isEndSession();
const isExpirationTimerUpdate = this.isExpirationTimerUpdate();
const isVerifiedChange = this.isVerifiedChange();
@ -1342,6 +1382,7 @@
// Rendered sync messages
isCallHistory ||
isGroupUpdate ||
isGroupV2Change ||
isEndSession ||
isExpirationTimerUpdate ||
isVerifiedChange ||
@ -1634,6 +1675,8 @@
// Because this is a partial group send, we manually construct the request like
// sendMessageToGroup does.
const groupV2 = conversation.getGroupV2Info();
promise = textsecure.messaging.sendMessage(
{
recipients,
@ -1645,10 +1688,13 @@
sticker: stickerWithData,
expireTimer: this.get('expireTimer'),
profileKey,
group: {
id: this.getConversation().get('groupId'),
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
groupV2,
group: groupV2
? null
: {
id: this.getConversation().get('groupId'),
type: textsecure.protobuf.GroupContext.Type.DELIVER,
},
},
options
);
@ -2392,19 +2438,79 @@
}
}
// We drop incoming messages for groups we already know about, which we're not a
// part of, except for group updates.
const ourUuid = textsecure.storage.user.getUuid();
const ourNumber = textsecure.storage.user.getNumber();
const isGroupUpdate =
const existingRevision = conversation.get('revision');
const isGroupV2 = Boolean(initialMessage.groupV2);
const isV2GroupUpdate =
initialMessage.groupV2 &&
(!existingRevision ||
initialMessage.groupV2.revision > existingRevision);
// GroupV2
if (isGroupV2) {
conversation.maybeRepairGroupV2(
_.pick(initialMessage.groupV2, [
'masterKey',
'secretParams',
'publicParams',
])
);
}
if (isV2GroupUpdate) {
const { revision, groupChange } = initialMessage.groupV2;
try {
await window.Signal.Groups.maybeUpdateGroup({
conversation,
groupChangeBase64: groupChange,
newRevision: revision,
timestamp: message.get('received_at'),
});
} catch (error) {
const errorText = error && error.stack ? error.stack : error;
window.log.error(
`handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}`
);
throw error;
}
}
const ourConversationId = ConversationController.getOurConversationId();
const senderId = ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
});
const isV1GroupUpdate =
initialMessage.group &&
initialMessage.group.type !==
textsecure.protobuf.GroupContext.Type.DELIVER;
// Drop an incoming GroupV2 message if we or the sender are not part of the group
// after applying the message's associated group chnages.
if (
type === 'incoming' &&
!conversation.isPrivate() &&
!conversation.hasMember(ourNumber || ourUuid) &&
!isGroupUpdate
isGroupV2 &&
(conversation.get('left') ||
!conversation.hasMember(ourConversationId) ||
!conversation.hasMember(senderId))
) {
window.log.warn(
`Received message destined for group ${conversation.idForLogging()}, which we or the sender are not a part of. Dropping.`
);
confirm();
return;
}
// We drop incoming messages for v1 groups we already know about, which we're not
// a part of, except for group updates. Because group v1 updates haven't been
// applied by this point.
if (
type === 'incoming' &&
!conversation.isPrivate() &&
!isGroupV2 &&
!isV1GroupUpdate &&
(conversation.get('left') ||
!conversation.hasMember(ourConversationId))
) {
window.log.warn(
`Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
@ -2488,7 +2594,9 @@
let attributes = {
...conversation.attributes,
};
if (dataMessage.group) {
// GroupV1
if (!isGroupV2 && dataMessage.group) {
const pendingGroupUpdate = [];
const memberConversations = await Promise.all(
dataMessage.group.membersE164.map(e164 =>
@ -2597,10 +2705,6 @@
conversation.set({ addedBy: message.getContactId() });
}
} else if (dataMessage.group.type === GROUP_TYPES.QUIT) {
const senderId = ConversationController.ensureContactIds({
e164: source,
uuid: sourceUuid,
});
const sender = ConversationController.get(senderId);
const inGroup = Boolean(
sender &&
@ -2638,6 +2742,17 @@
}
}
// Drop empty messages after. This needs to happen after the initial
// message.set call and after GroupV1 processing to make sure all possible
// properties are set before we determine that a message is empty.
if (message.isEmpty()) {
window.log.info(
`handleDataMessage: Dropping empty message ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
);
confirm();
return;
}
if (type === 'outgoing') {
const receipts = Whisper.DeliveryReceipts.forMessage(
conversation,
@ -2652,61 +2767,66 @@
})
);
}
attributes.active_at = now;
conversation.set(attributes);
if (message.isExpirationTimerUpdate()) {
message.set({
expirationTimerUpdate: {
source,
sourceUuid,
expireTimer: dataMessage.expireTimer,
},
});
conversation.set({ expireTimer: dataMessage.expireTimer });
} else if (dataMessage.expireTimer) {
if (dataMessage.expireTimer) {
message.set({ expireTimer: dataMessage.expireTimer });
}
// NOTE: Remove once the above uses
// `Conversation::updateExpirationTimer`:
const { expireTimer } = dataMessage;
const shouldLogExpireTimerChange =
message.isExpirationTimerUpdate() || expireTimer;
if (shouldLogExpireTimerChange) {
window.log.info("Update conversation 'expireTimer'", {
id: conversation.idForLogging(),
expireTimer,
source: 'handleDataMessage',
});
}
if (!isGroupV2) {
if (message.isExpirationTimerUpdate()) {
message.set({
expirationTimerUpdate: {
source,
sourceUuid,
expireTimer: dataMessage.expireTimer,
},
});
conversation.set({ expireTimer: dataMessage.expireTimer });
}
if (!message.isEndSession()) {
if (dataMessage.expireTimer) {
if (
dataMessage.expireTimer !== conversation.get('expireTimer')
// NOTE: Remove once the above calls this.model.updateExpirationTimer()
const { expireTimer } = dataMessage;
const shouldLogExpireTimerChange =
message.isExpirationTimerUpdate() || expireTimer;
if (shouldLogExpireTimerChange) {
window.log.info("Update conversation 'expireTimer'", {
id: conversation.idForLogging(),
expireTimer,
source: 'handleDataMessage',
});
}
if (!message.isEndSession()) {
if (dataMessage.expireTimer) {
if (
dataMessage.expireTimer !== conversation.get('expireTimer')
) {
conversation.updateExpirationTimer(
dataMessage.expireTimer,
source,
message.get('received_at'),
{
fromGroupUpdate: message.isGroupUpdate(),
}
);
}
} else if (
conversation.get('expireTimer') &&
// We only turn off timers if it's not a group update
!message.isGroupUpdate()
) {
conversation.updateExpirationTimer(
dataMessage.expireTimer,
null,
source,
message.get('received_at'),
{
fromGroupUpdate: message.isGroupUpdate(),
}
message.get('received_at')
);
}
} else if (
conversation.get('expireTimer') &&
// We only turn off timers if it's not a group update
!message.isGroupUpdate()
) {
conversation.updateExpirationTimer(
null,
source,
message.get('received_at')
);
}
}
if (type === 'incoming') {
const readSync = Whisper.ReadSyncs.forMessage(message);
if (readSync) {
@ -2804,17 +2924,6 @@
}
}
// Drop empty messages. This needs to happen after the initial
// message.set call to make sure all possible properties are set
// before we determine that a message is empty.
if (message.isEmpty()) {
window.log.info(
`Dropping empty datamessage ${message.idForLogging()} in conversation ${conversation.idForLogging()}`
);
confirm();
return;
}
const conversationTimestamp = conversation.get('timestamp');
if (
!conversationTimestamp ||

View file

@ -10,6 +10,7 @@ const APP_ROOT_PATH = path.join(__dirname, '..', '..', '..');
const PHONE_NUMBER_PATTERN = /\+\d{7,12}(\d{3})/g;
const UUID_PATTERN = /[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{9}([0-9A-F]{3})/gi;
const GROUP_ID_PATTERN = /(group\()([^)]+)(\))/g;
const GROUP_V2_ID_PATTERN = /(groupv2\()([^=)]+)(=?=?\))/g;
const REDACTION_PLACEHOLDER = '[REDACTED]';
// _redactPath :: Path -> String -> String
@ -80,11 +81,21 @@ exports.redactGroupIds = text => {
throw new TypeError("'text' must be a string");
}
return text.replace(
GROUP_ID_PATTERN,
(match, before, id, after) =>
`${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice(-3)}${after}`
);
return text
.replace(
GROUP_ID_PATTERN,
(match, before, id, after) =>
`${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice(
-3
)}${after}`
)
.replace(
GROUP_V2_ID_PATTERN,
(match, before, id, after) =>
`${before}${REDACTION_PLACEHOLDER}${removeNewlines(id).slice(
-3
)}${after}`
);
};
// redactSensitivePaths :: String -> String

View file

@ -9,6 +9,8 @@ const {
const Data = require('../../ts/sql/Client').default;
const Emojis = require('./emojis');
const EmojiLib = require('../../ts/components/emoji/lib');
const Groups = require('../../ts/groups');
const GroupChange = require('../../ts/groupChange');
const IndexedDB = require('./indexeddb');
const Notifications = require('../../ts/notifications');
const OS = require('../../ts/OS');
@ -108,6 +110,9 @@ const { IdleDetector } = require('./idle_detector');
const MessageDataMigrator = require('./messages_data_migrator');
// Processes / Services
const {
initializeGroupCredentialFetcher,
} = require('../../ts/services/groupCredentialFetcher');
const {
initializeNetworkObserver,
} = require('../../ts/services/networkObserver');
@ -333,6 +338,7 @@ exports.setup = (options = {}) => {
calling,
eraseAllStorageServiceState,
handleUnknownRecords,
initializeGroupCredentialFetcher,
initializeNetworkObserver,
initializeUpdateListener,
notify,
@ -378,6 +384,8 @@ exports.setup = (options = {}) => {
Data,
Emojis,
EmojiLib,
Groups,
GroupChange,
IndexedDB,
LinkPreviews,
Metadata,

View file

@ -1,16 +1,12 @@
/* global crypto, window */
/* global window */
const { isFunction, isNumber } = require('lodash');
const {
arrayBufferToBase64,
base64ToArrayBuffer,
computeHash,
} = require('../../../ts/Crypto');
async function computeHash(arraybuffer) {
const hash = await crypto.subtle.digest({ name: 'SHA-512' }, arraybuffer);
return arrayBufferToBase64(hash);
}
function buildAvatarUpdater({ field }) {
return async (conversation, data, options = {}) => {
if (!conversation) {

View file

@ -20,7 +20,7 @@
if (conversation.isPrivate()) {
ids = [conversation.id];
} else {
ids = conversation.get('members');
ids = conversation.getMemberIds();
}
const receipts = this.filter(
receipt =>

View file

@ -28,7 +28,7 @@
className: 'contact-wrapper',
Component: window.Signal.Components.ContactListItem,
props: {
...this.model.cachedProps,
...this.model.format(),
onClick: this.showIdentity.bind(this),
},
});

View file

@ -215,6 +215,9 @@
Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({
template: i18n('maximumAttachments'),
});
Whisper.TimerConflictToast = Whisper.ToastView.extend({
template: i18n('GroupV2--timerConflict'),
});
Whisper.ConversationLoadingScreen = Whisper.View.extend({
templateName: 'conversation-loading-screen',
@ -311,6 +314,13 @@
this.model.updateSharedGroups.bind(this.model),
FIVE_MINUTES
);
this.model.throttledFetchLatestGroupV2Data =
this.model.throttledFetchLatestGroupV2Data ||
_.throttle(
this.model.fetchLatestGroupV2Data.bind(this.model),
FIVE_MINUTES
);
this.debouncedMaybeGrabLinkPreview = _.debounce(
this.maybeGrabLinkPreview.bind(this),
200
@ -385,8 +395,13 @@
leftGroup: this.model.get('left'),
expirationSettingName,
disableTimerChanges:
this.model.get('left') ||
!this.model.getAccepted() ||
!this.model.canChangeTimer(),
showBackButton: Boolean(this.panels && this.panels.length),
expirationSettingName,
timerOptions: Whisper.ExpirationTimerOptions.map(item => ({
name: item.getName(),
value: item.get('seconds'),
@ -1826,6 +1841,8 @@
this.setQuoteMessage(quotedMessageId);
}
this.model.throttledFetchLatestGroupV2Data();
const statusPromise = this.model.throttledGetProfiles();
// eslint-disable-next-line more/no-then
this.statusFetch = statusPromise.then(() =>
@ -2044,7 +2061,18 @@
async showMembers(e, providedMembers, options = {}) {
_.defaults(options, { needVerify: false });
const model = providedMembers || this.model.contactCollection;
let model = providedMembers || this.model.contactCollection;
if (!providedMembers && this.model.get('groupVersion') === 2) {
model = new Whisper.GroupConversationCollection(
this.model.get('membersV2').map(({ conversationId, role }) => ({
conversation: ConversationController.get(conversationId),
isAdmin:
role === window.textsecure.protobuf.Member.Role.ADMINISTRATOR,
}))
);
}
const view = new Whisper.GroupMemberList({
model,
// we pass this in to allow nested panels
@ -2496,11 +2524,17 @@
this.model.endSession();
},
setDisappearingMessages(seconds) {
if (seconds > 0) {
this.model.updateExpirationTimer(seconds);
} else {
this.model.updateExpirationTimer(null);
async setDisappearingMessages(seconds) {
try {
if (seconds > 0) {
await this.model.updateExpirationTimer(seconds);
} else {
await this.model.updateExpirationTimer(null);
}
} catch (error) {
if (error.code === 409) {
this.showToast(Whisper.TimerConflictToast);
}
}
},

View file

@ -18,7 +18,7 @@
}
const protos = result.build('signalservice');
if (!protos) {
const text = `Error loading protos from ${filename} (root: ${window.PROTO_ROOT})`;
const text = `Error loading protos from ${filename} - no exported types! (root: ${window.PROTO_ROOT})`;
window.log.error(text);
throw new Error(text);
}
@ -41,4 +41,7 @@
// Metadata-specific protos
loadProtoBufs('UnidentifiedDelivery.proto');
// Groups
loadProtoBufs('Groups.proto');
})();

View file

@ -19,6 +19,8 @@ try {
window.PROTO_ROOT = 'protos';
const config = require('url').parse(window.location.toString(), true).query;
window.GV2 = false;
let title = config.name;
if (config.environment !== 'production') {
title += ` - ${config.environment}`;
@ -375,12 +377,14 @@ try {
paths.forEach(path => {
const val = _.get(obj, path);
if (val) {
if (!window.isValidGuid(val)) {
if (!val || !window.isValidGuid(val)) {
window.log.warn(
`Normalizing invalid uuid: ${val} at path ${path} in context "${context}"`
);
}
_.set(obj, path, val.toLowerCase());
if (val && val.toLowerCase) {
_.set(obj, path, val.toLowerCase());
}
}
});
};

152
protos/Groups.proto Normal file
View file

@ -0,0 +1,152 @@
syntax = "proto3";
package signalservice;
option java_package = "org.whispersystems.signalservice.protos.groups";
option java_multiple_files = true;
message AvatarUploadAttributes {
string key = 1;
string credential = 2;
string acl = 3;
string algorithm = 4;
string date = 5;
string policy = 6;
string signature = 7;
}
message Member {
enum Role {
UNKNOWN = 0;
DEFAULT = 1; // Normal member
ADMINISTRATOR = 2; // Group admin
}
bytes userId = 1; // The UuidCiphertext
Role role = 2;
bytes profileKey = 3; // The ProfileKeyCiphertext
bytes presentation = 4; // ProfileKeyCredentialPresentation
uint32 joinedAtVersion = 5; // The Group.version this member joined at
}
message PendingMember {
Member member = 1; // The invited member
bytes addedByUserId = 2; // The UID who invited this member
uint64 timestamp = 3; // The time the invitation occurred
}
message AccessControl {
enum AccessRequired {
UNKNOWN = 0;
MEMBER = 2; // Any group member can make the modification
ADMINISTRATOR = 3; // Only administrators can make the modification
}
AccessRequired attributes = 1; // Who can modify the group title, avatar, disappearing messages timer
AccessRequired members = 2; // Who can add people to the group
}
message Group {
bytes publicKey = 1; // GroupPublicParams
bytes title = 2; // Encrypted title
string avatar = 3; // Pointer to encrypted avatar (key from AvatarUploadAttributes)
bytes disappearingMessagesTimer = 4; // Encrypted timer
AccessControl accessControl = 5;
uint32 version = 6; // Current group version number
repeated Member members = 7;
repeated PendingMember pendingMembers = 8;
}
message GroupChange {
message Actions {
message AddMemberAction {
Member added = 1;
}
message DeleteMemberAction {
bytes deletedUserId = 1;
}
message ModifyMemberRoleAction {
bytes userId = 1;
Member.Role role = 2;
}
message ModifyMemberProfileKeyAction {
bytes presentation = 1;
}
message AddPendingMemberAction {
PendingMember added = 1;
}
message DeletePendingMemberAction {
bytes deletedUserId = 1;
}
message PromotePendingMemberAction {
bytes presentation = 1;
}
message ModifyTitleAction {
bytes title = 1;
}
message ModifyAvatarAction {
string avatar = 1;
}
message ModifyDisappearingMessagesTimerAction {
bytes timer = 1;
}
message ModifyAttributesAccessControlAction {
AccessControl.AccessRequired attributesAccess = 1;
}
message ModifyAvatarAccessControlAction {
AccessControl.AccessRequired avatarAccess = 1;
}
message ModifyMembersAccessControlAction {
AccessControl.AccessRequired membersAccess = 1;
}
bytes sourceUuid = 1; // Who made the change
uint32 version = 2; // The change version number
repeated AddMemberAction addMembers = 3; // Members added
repeated DeleteMemberAction deleteMembers = 4; // Members deleted
repeated ModifyMemberRoleAction modifyMemberRoles = 5; // Modified member roles
repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; // Modified member profile keys
repeated AddPendingMemberAction addPendingMembers = 7; // Pending members added
repeated DeletePendingMemberAction deletePendingMembers = 8; // Pending members deleted
repeated PromotePendingMemberAction promotePendingMembers = 9; // Pending invitations accepted
ModifyTitleAction modifyTitle = 10; // Changed title
ModifyAvatarAction modifyAvatar = 11; // Changed avatar
ModifyDisappearingMessagesTimerAction modifyDisappearingMessagesTimer = 12; // Changed timer
ModifyAttributesAccessControlAction modifyAttributesAccess = 13; // Changed attributes access control
ModifyMembersAccessControlAction modifyMemberAccess = 14; // Changed membership access control
}
bytes actions = 1; // The serialized actions
bytes serverSignature = 2; // Servers signature over serialized actions
}
message GroupChanges {
message GroupChangeState {
GroupChange groupChange = 1;
Group groupState = 2;
}
repeated GroupChangeState groupChanges = 1;
}
message GroupAttributeBlob {
oneof content {
string title = 1;
bytes avatar = 2;
uint32 disappearingMessagesDuration = 3;
}
}

View file

@ -2581,7 +2581,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
color: $color-gray-05;
}
}
@ -2603,7 +2603,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
@include color-svg('../images/icons/v2/timer-24.svg', $color-gray-60);
}
@include dark-theme {
@include color-svg('../images/icons/v2/timer-24.svg', $color-gray-25);
@include color-svg('../images/icons/v2/timer-24.svg', $color-gray-05);
}
}
@ -2617,7 +2617,7 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
@include dark-theme {
@include color-svg(
'../images/icons/v2/timer-disabled-24.svg',
$color-gray-25
$color-gray-05
);
}
}
@ -2689,6 +2689,11 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
.module-contact-list-item__text {
margin-left: 8px;
display: flex;
flex-direction: row;
flex-grow: 1;
}
.module-contact-list-item__text__name {
@ -2722,6 +2727,14 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05',
}
}
.module-contact-list-item__admin {
flex-grow: 1;
text-align: right;
height: 100%;
@include font-body-2-bold;
}
// Module: In Contacts Icon
.module-in-contacts-icon__icon {
@ -9001,6 +9014,45 @@ button.module-image__border-overlay:focus {
}
}
// Module: GroupV2 Change
.module-group-v2-change {
@include font-body-1;
margin-left: 2em;
margin-right: 2em;
text-align: center;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-05;
}
}
.module-group-v2-change--icon {
@include light-theme {
@include color-svg(
'../images/icons/v2/group-outline-20.svg',
$color-gray-60
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v2/group-outline-20.svg',
$color-gray-05
);
}
height: 20px;
width: 20px;
margin-left: auto;
margin-right: auto;
}
/* Third-party module: react-tooltip-lite */
.react-tooltip-lite {

View file

@ -161,7 +161,7 @@ describe('Message', () => {
left: 'You',
},
}).getNotificationData(),
{ text: 'You left the group.' }
{ text: 'You are no longer a member of the group.' }
);
});

View file

@ -59,6 +59,18 @@ describe('Privacy', () => {
'and group([REDACTED]hij)';
assert.equal(actual, expected);
});
it('should remove newlines from redacted group V2 IDs', () => {
const text =
'This is a log line with three group IDs: groupv2(abcd32341a==)\n' +
'and groupv2(abcd32341ad=) and and groupv2(abcd32341ade)';
const actual = Privacy.redactGroupIds(text);
const expected =
'This is a log line with three group IDs: groupv2([REDACTED]41a==)\n' +
'and groupv2([REDACTED]1ad=) and and groupv2([REDACTED]ade)';
assert.equal(actual, expected);
});
});
describe('redactAll', () => {

View file

@ -217,7 +217,7 @@ export class ConversationController {
return null;
}
getOurConversationId() {
getOurConversationId(): string | undefined {
const e164 = window.textsecure.storage.user.getNumber();
const uuid = window.textsecure.storage.user.getUuid();
return this.ensureContactIds({ e164, uuid, highTrust: true });
@ -238,7 +238,7 @@ export class ConversationController {
e164?: string;
uuid?: string;
highTrust?: boolean;
}) {
}): string | undefined {
// Check for at least one parameter being provided. This is necessary
// because this path can be called on startup to resolve our own ID before
// our phone number or UUID are known. The existing behavior in these
@ -546,7 +546,7 @@ export class ConversationController {
* ensures the existence of a group conversation and returns a string
* representing the local database ID of the group conversation.
*/
ensureGroup(groupId: string, additionalInitProps = {}) {
ensureGroup(groupId: string, additionalInitProps = {}): string {
return this.getOrCreate(groupId, 'group', additionalInitProps).get('id');
}
/**

View file

@ -62,6 +62,11 @@ export async function deriveStickerPackKey(packKey: ArrayBuffer) {
return concatenateBytes(part1, part2);
}
export async function computeHash(data: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest({ name: 'SHA-512' }, data);
return arrayBufferToBase64(hash);
}
// High-level Operations
export async function encryptDeviceName(

View file

@ -1,7 +1,7 @@
import { get, throttle } from 'lodash';
import { WebAPIType } from './textsecure/WebAPI';
type ConfigKeyType = 'desktop.messageRequests';
type ConfigKeyType = 'desktop.messageRequests' | 'desktop.gv2' | 'desktop.cds';
type ConfigValueType = {
name: ConfigKeyType;
enabled: boolean;
@ -66,6 +66,7 @@ export const refreshRemoteConfig = async () => {
// If enablement changes at all, notify listeners
const currentListeners = listeners[name] || [];
if (previouslyEnabled !== enabled) {
window.log.info(`Remote Config: Flag ${name} has been enabled`);
currentListeners.forEach(listener => {
listener(value);
});

View file

@ -69,6 +69,33 @@ storiesOf('Components/ContactListItem', module)
/>
);
})
.add('With name and profile, admin', () => {
return (
<ContactListItem
i18n={i18n}
isAdmin
title="Someone 🔥 Somewhere"
name="Someone 🔥 Somewhere"
phoneNumber="(202) 555-0011"
profileName="🔥Flames🔥"
isVerified
avatarPath={gifUrl}
onClick={onClick}
/>
);
})
.add('With just number, admin', () => {
return (
<ContactListItem
i18n={i18n}
isAdmin
title="(202) 555-0011"
phoneNumber="(202) 555-0011"
avatarPath={gifUrl}
onClick={onClick}
/>
);
})
.add('With name and profile, no avatar', () => {
return (
<ContactListItem

View file

@ -9,16 +9,17 @@ import { LocalizerType } from '../types/Util';
import { ColorType } from '../types/Colors';
interface Props {
title: string;
phoneNumber?: string;
isMe?: boolean;
name?: string;
color?: ColorType;
isVerified?: boolean;
profileName?: string;
avatarPath?: string;
color?: ColorType;
i18n: LocalizerType;
isAdmin?: boolean;
isMe?: boolean;
isVerified?: boolean;
name?: string;
onClick?: () => void;
phoneNumber?: string;
profileName?: string;
title: string;
}
export class ContactListItem extends React.Component<Props> {
@ -51,13 +52,14 @@ export class ContactListItem extends React.Component<Props> {
public render() {
const {
i18n,
isAdmin,
isMe,
isVerified,
name,
onClick,
isMe,
phoneNumber,
profileName,
title,
isVerified,
} = this.props;
const displayName = isMe ? i18n('you') : title;
@ -76,23 +78,30 @@ export class ContactListItem extends React.Component<Props> {
>
{this.renderAvatar()}
<div className="module-contact-list-item__text">
<div className="module-contact-list-item__text__name">
<Emojify text={displayName} />
{shouldShowIcon ? (
<span>
{' '}
<InContactsIcon i18n={i18n} />
</span>
) : null}
</div>
<div className="module-contact-list-item__text__additional-data">
{showVerified ? (
<div className="module-contact-list-item__text__verified-icon" />
) : null}
{showVerified ? ` ${i18n('verified')}` : null}
{showVerified && showNumber ? ' ∙ ' : null}
{showNumber ? phoneNumber : null}
<div className="module-contact-list-item__left">
<div className="module-contact-list-item__text__name">
<Emojify text={displayName} />
{shouldShowIcon ? (
<span>
{' '}
<InContactsIcon i18n={i18n} />
</span>
) : null}
</div>
<div className="module-contact-list-item__text__additional-data">
{showVerified ? (
<div className="module-contact-list-item__text__verified-icon" />
) : null}
{showVerified ? ` ${i18n('verified')}` : null}
{showVerified && showNumber ? ' ∙ ' : null}
{showNumber ? phoneNumber : null}
</div>
</div>
{isAdmin ? (
<div className="module-contact-list-item__admin">
{i18n('GroupV2--admin')}
</div>
) : null}
</div>
</button>
);

View file

@ -228,7 +228,7 @@ const stories: Array<ConversationHeaderStory> = [
phoneNumber: '',
id: '2',
type: 'group',
leftGroup: true,
disableTimerChanges: true,
expirationSettingName: '10 seconds',
timerOptions: [
{

View file

@ -35,8 +35,8 @@ export interface PropsDataType {
isVerified?: boolean;
isMe?: boolean;
isArchived?: boolean;
leftGroup?: boolean;
disableTimerChanges?: boolean;
expirationSettingName?: string;
muteExpirationLabel?: string;
showBackButton?: boolean;
@ -286,12 +286,12 @@ export class ConversationHeader extends React.Component<PropsType> {
public renderMenu(triggerId: string) {
const {
disableTimerChanges,
i18n,
isAccepted,
isMe,
type,
isArchived,
leftGroup,
muteExpirationLabel,
onDeleteMessages,
onResetSession,
@ -329,7 +329,7 @@ export class ConversationHeader extends React.Component<PropsType> {
return (
<ContextMenu id={triggerId}>
{!leftGroup && isAccepted ? (
{disableTimerChanges ? null : (
<SubMenu title={disappearingTitle}>
{(timerOptions || []).map(item => (
<MenuItem
@ -342,7 +342,7 @@ export class ConversationHeader extends React.Component<PropsType> {
</MenuItem>
))}
</SubMenu>
) : null}
)}
<SubMenu title={muteTitle}>
{muteOptions.map(item => (
<MenuItem

View file

@ -0,0 +1,866 @@
import * as React from 'react';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { GroupV2ChangeType } from '../../groups';
import { SmartContactRendererType } from '../../groupChange';
import { GroupV2Change } from './GroupV2Change';
const i18n = setupI18n('en', enMessages);
const OUR_ID = 'OUR_ID';
const CONTACT_A = 'CONTACT_A';
const CONTACT_B = 'CONTACT_B';
const CONTACT_C = 'CONTACT_C';
const ADMIN_A = 'ADMIN_A';
const INVITEE_A = 'INVITEE_A';
// tslint:disable-next-line no-unnecessary-class
class AccessControlEnum {
static UNKNOWN = 0;
static ADMINISTRATOR = 1;
static ANY = 2;
static MEMBER = 3;
}
// tslint:disable-next-line no-unnecessary-class
class RoleEnum {
static UNKNOWN = 0;
static ADMINISTRATOR = 1;
static DEFAULT = 2;
}
const renderContact: SmartContactRendererType = (conversationId: string) => (
<React.Fragment key={conversationId}>
{`Conversation(${conversationId})`}
</React.Fragment>
);
const renderChange = (change: GroupV2ChangeType) => (
<GroupV2Change
AccessControlEnum={AccessControlEnum}
change={change}
i18n={i18n}
ourConversationId={OUR_ID}
renderContact={renderContact}
RoleEnum={RoleEnum}
/>
);
storiesOf('Components/Conversation/GroupV2Change', module)
.add('Multiple', () => {
return (
<>
{renderChange({
from: CONTACT_A,
details: [
{
type: 'title',
newTitle: 'Saturday Running',
},
{
type: 'avatar',
removed: false,
},
{
type: 'member-add',
conversationId: OUR_ID,
},
{
type: 'member-privilege',
conversationId: OUR_ID,
newPrivilege: RoleEnum.ADMINISTRATOR,
},
],
})}
</>
);
})
.add('Title', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'title',
newTitle: 'Saturday Running',
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'title',
newTitle: 'Saturday Running',
},
],
})}
{renderChange({
details: [
{
type: 'title',
newTitle: 'Saturday Running',
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'title',
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'title',
},
],
})}
{renderChange({
details: [
{
type: 'title',
},
],
})}
</>
);
})
.add('Avatar', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'avatar',
removed: false,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'avatar',
removed: false,
},
],
})}
{renderChange({
details: [
{
type: 'avatar',
removed: false,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'avatar',
removed: true,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'avatar',
removed: true,
},
],
})}
{renderChange({
details: [
{
type: 'avatar',
removed: true,
},
],
})}
</>
);
})
.add('Access (Attributes)', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'access-attributes',
newPrivilege: AccessControlEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'access-attributes',
newPrivilege: AccessControlEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
details: [
{
type: 'access-attributes',
newPrivilege: AccessControlEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'access-attributes',
newPrivilege: AccessControlEnum.MEMBER,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'access-attributes',
newPrivilege: AccessControlEnum.MEMBER,
},
],
})}
{renderChange({
details: [
{
type: 'access-attributes',
newPrivilege: AccessControlEnum.MEMBER,
},
],
})}
</>
);
})
.add('Access (Members)', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'access-members',
newPrivilege: AccessControlEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'access-members',
newPrivilege: AccessControlEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
details: [
{
type: 'access-members',
newPrivilege: AccessControlEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'access-members',
newPrivilege: AccessControlEnum.MEMBER,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'access-members',
newPrivilege: AccessControlEnum.MEMBER,
},
],
})}
{renderChange({
details: [
{
type: 'access-members',
newPrivilege: AccessControlEnum.MEMBER,
},
],
})}
</>
);
})
.add('Member Add', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'member-add',
conversationId: OUR_ID,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'member-add',
conversationId: OUR_ID,
},
],
})}
{renderChange({
details: [
{
type: 'member-add',
conversationId: OUR_ID,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'member-add',
conversationId: CONTACT_A,
},
],
})}
{renderChange({
from: CONTACT_B,
details: [
{
type: 'member-add',
conversationId: CONTACT_A,
},
],
})}
{renderChange({
details: [
{
type: 'member-add',
conversationId: CONTACT_A,
},
],
})}
</>
);
})
.add('Member Add - from invite', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'member-add-from-invite',
conversationId: OUR_ID,
inviter: CONTACT_A,
},
],
})}
{renderChange({
from: CONTACT_B,
details: [
{
type: 'member-add-from-invite',
conversationId: CONTACT_A,
inviter: OUR_ID,
},
],
})}
{renderChange({
details: [
{
type: 'member-add-from-invite',
conversationId: CONTACT_A,
inviter: CONTACT_B,
},
],
})}
</>
);
})
.add('Member Remove', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'member-remove',
conversationId: OUR_ID,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'member-remove',
conversationId: OUR_ID,
},
],
})}
{renderChange({
details: [
{
type: 'member-remove',
conversationId: OUR_ID,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'member-remove',
conversationId: CONTACT_A,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'member-remove',
conversationId: CONTACT_A,
},
],
})}
{renderChange({
from: CONTACT_B,
details: [
{
type: 'member-remove',
conversationId: CONTACT_A,
},
],
})}
{renderChange({
details: [
{
type: 'member-remove',
conversationId: CONTACT_A,
},
],
})}
</>
);
})
// tslint:disable-next-line max-func-body-length
.add('Member Privilege', () => {
return (
<>
{renderChange({
from: CONTACT_A,
details: [
{
type: 'member-privilege',
conversationId: OUR_ID,
newPrivilege: RoleEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
details: [
{
type: 'member-privilege',
conversationId: OUR_ID,
newPrivilege: RoleEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'member-privilege',
conversationId: CONTACT_A,
newPrivilege: RoleEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'member-privilege',
conversationId: CONTACT_A,
newPrivilege: RoleEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
details: [
{
type: 'member-privilege',
conversationId: CONTACT_A,
newPrivilege: RoleEnum.ADMINISTRATOR,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'member-privilege',
conversationId: OUR_ID,
newPrivilege: RoleEnum.DEFAULT,
},
],
})}
{renderChange({
details: [
{
type: 'member-privilege',
conversationId: OUR_ID,
newPrivilege: RoleEnum.DEFAULT,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'member-privilege',
conversationId: CONTACT_A,
newPrivilege: RoleEnum.DEFAULT,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'member-privilege',
conversationId: CONTACT_A,
newPrivilege: RoleEnum.DEFAULT,
},
],
})}
{renderChange({
details: [
{
type: 'member-privilege',
conversationId: CONTACT_A,
newPrivilege: RoleEnum.DEFAULT,
},
],
})}
</>
);
})
.add('Pending Add - one', () => {
return (
<>
{renderChange({
from: CONTACT_A,
details: [
{
type: 'pending-add-one',
conversationId: OUR_ID,
},
],
})}
{renderChange({
details: [
{
type: 'pending-add-one',
conversationId: OUR_ID,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-add-one',
conversationId: INVITEE_A,
},
],
})}
{renderChange({
from: CONTACT_B,
details: [
{
type: 'pending-add-one',
conversationId: INVITEE_A,
},
],
})}
{renderChange({
details: [
{
type: 'pending-add-one',
conversationId: INVITEE_A,
},
],
})}
</>
);
})
.add('Pending Add - many', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-add-many',
count: 5,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'pending-add-many',
count: 5,
},
],
})}
{renderChange({
details: [
{
type: 'pending-add-many',
count: 5,
},
],
})}
</>
);
})
// tslint:disable-next-line max-func-body-length
.add('Pending Remove - one', () => {
return (
<>
{renderChange({
from: INVITEE_A,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: OUR_ID,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: OUR_ID,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: OUR_ID,
},
],
})}
{renderChange({
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: OUR_ID,
},
],
})}
{renderChange({
from: INVITEE_A,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
},
],
})}
{renderChange({
from: INVITEE_A,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: CONTACT_B,
},
],
})}
{renderChange({
from: CONTACT_C,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: CONTACT_B,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: CONTACT_B,
},
],
})}
{renderChange({
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
inviter: CONTACT_B,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
},
],
})}
{renderChange({
from: CONTACT_B,
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
},
],
})}
{renderChange({
details: [
{
type: 'pending-remove-one',
conversationId: INVITEE_A,
},
],
})}
</>
);
})
.add('Pending Remove - many', () => {
return (
<>
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-remove-many',
count: 5,
inviter: OUR_ID,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'pending-remove-many',
count: 5,
inviter: OUR_ID,
},
],
})}
{renderChange({
details: [
{
type: 'pending-remove-many',
count: 5,
inviter: OUR_ID,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-remove-many',
count: 5,
inviter: CONTACT_A,
},
],
})}
{renderChange({
from: ADMIN_A,
details: [
{
type: 'pending-remove-many',
count: 5,
inviter: CONTACT_A,
},
],
})}
{renderChange({
details: [
{
type: 'pending-remove-many',
count: 5,
inviter: CONTACT_A,
},
],
})}
{renderChange({
from: OUR_ID,
details: [
{
type: 'pending-remove-many',
count: 5,
},
],
})}
{renderChange({
from: CONTACT_A,
details: [
{
type: 'pending-remove-many',
count: 5,
},
],
})}
{renderChange({
details: [
{
type: 'pending-remove-many',
count: 5,
},
],
})}
</>
);
});

View file

@ -0,0 +1,60 @@
import * as React from 'react';
import { ReplacementValuesType } from '../../types/I18N';
import { FullJSXType, Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
import { GroupV2ChangeType } from '../../groups';
import { renderChange, SmartContactRendererType } from '../../groupChange';
import { AccessControlClass, MemberClass } from '../../textsecure.d';
export type PropsDataType = {
ourConversationId: string;
change: GroupV2ChangeType;
AccessControlEnum: typeof AccessControlClass.AccessRequired;
RoleEnum: typeof MemberClass.Role;
};
export type PropsHousekeepingType = {
i18n: LocalizerType;
renderContact: SmartContactRendererType;
};
export type PropsType = PropsDataType & PropsHousekeepingType;
function renderStringToIntl(
id: string,
i18n: LocalizerType,
components?: Array<FullJSXType> | ReplacementValuesType<FullJSXType>
): FullJSXType {
return <Intl id={id} i18n={i18n} components={components} />;
}
export function GroupV2Change(props: PropsType): React.ReactElement {
const {
AccessControlEnum,
change,
i18n,
ourConversationId,
renderContact,
RoleEnum,
} = props;
return (
<div className="module-group-v2-change">
<div className="module-group-v2-change--icon" />
{renderChange(change, {
AccessControlEnum,
i18n,
ourConversationId,
renderContact,
renderString: renderStringToIntl,
RoleEnum,
}).map((item: FullJSXType, index: number) => (
<div key={index}>{item}</div>
))}
</div>
);
}

View file

@ -256,6 +256,7 @@ const renderItem = (id: string) => (
i18n={i18n}
conversationId=""
conversationAccepted
renderContact={() => '*ContactName*'}
{...actions()}
/>
);

View file

@ -28,6 +28,10 @@ const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({
/>
);
const renderContact = (conversationId: string) => (
<React.Fragment key={conversationId}>{conversationId}</React.Fragment>
);
const getDefaultProps = () => ({
conversationId: 'conversation-id',
conversationAccepted: true,
@ -55,6 +59,8 @@ const getDefaultProps = () => ({
scrollToQuotedMessage: action('scrollToQuotedMessage'),
downloadNewVersion: action('downloadNewVersion'),
showIdentity: action('showIdentity'),
renderContact,
renderEmojiPicker,
});

View file

@ -35,6 +35,11 @@ import {
GroupNotification,
PropsData as GroupNotificationProps,
} from './GroupNotification';
import {
GroupV2Change,
PropsDataType as GroupV2ChangeProps,
} from './GroupV2Change';
import { SmartContactRendererType } from '../../groupChange';
import { ResetSessionNotification } from './ResetSessionNotification';
import {
ProfileChangeNotification,
@ -73,6 +78,10 @@ type GroupNotificationType = {
type: 'groupNotification';
data: GroupNotificationProps;
};
type GroupV2ChangeType = {
type: 'groupV2Change';
data: GroupV2ChangeProps;
};
type ResetSessionNotificationType = {
type: 'resetSessionNotification';
data: null;
@ -85,6 +94,7 @@ type ProfileChangeNotificationType = {
export type TimelineItemType =
| CallHistoryType
| GroupNotificationType
| GroupV2ChangeType
| LinkNotificationType
| MessageType
| ProfileChangeNotificationType
@ -101,6 +111,7 @@ type PropsLocalType = {
id: string;
isSelected: boolean;
selectMessage: (messageId: string, conversationId: string) => unknown;
renderContact: SmartContactRendererType;
i18n: LocalizerType;
};
@ -120,6 +131,7 @@ export class TimelineItem extends React.PureComponent<PropsType> {
isSelected,
item,
i18n,
renderContact,
selectMessage,
} = this.props;
@ -165,6 +177,14 @@ export class TimelineItem extends React.PureComponent<PropsType> {
notification = (
<GroupNotification {...this.props} {...item.data} i18n={i18n} />
);
} else if (item.type === 'groupV2Change') {
notification = (
<GroupV2Change
renderContact={renderContact}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'resetSessionNotification') {
notification = (
<ResetSessionNotification {...this.props} {...item.data} i18n={i18n} />
@ -174,7 +194,12 @@ export class TimelineItem extends React.PureComponent<PropsType> {
<ProfileChangeNotification {...this.props} {...item.data} i18n={i18n} />
);
} else {
throw new Error('TimelineItem: Unknown type!');
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive
// with our if/else checks above, but also log out the type we don't understand if
// we encounter it at runtime.
const unknownItem: never = item;
const asItem = unknownItem as TimelineItemType;
throw new Error(`TimelineItem: Unknown type: ${asItem.type}`);
}
return (

View file

@ -6,7 +6,7 @@ import { Intl } from '../Intl';
import { LocalizerType } from '../../types/Util';
export type PropsData = {
type: 'fromOther' | 'fromMe' | 'fromSync';
type: 'fromOther' | 'fromMe' | 'fromSync' | 'fromMember';
phoneNumber?: string;
profileName?: string;
title: string;
@ -66,6 +66,10 @@ export class TimerNotification extends React.Component<Props> {
return disabled
? i18n('disappearingMessagesDisabled')
: i18n('timerSetOnSync', [timespan]);
case 'fromMember':
return disabled
? i18n('disappearingMessagesDisabledByMember')
: i18n('timerSetByMember', [timespan]);
default:
console.warn('TimerNotification: unsupported type provided:', type);

536
ts/groupChange.ts Normal file
View file

@ -0,0 +1,536 @@
import { FullJSXType } from './components/Intl';
import { LocalizerType } from './types/Util';
import { ReplacementValuesType } from './types/I18N';
import { missingCaseError } from './util/missingCaseError';
import { AccessControlClass, MemberClass } from './textsecure.d';
import { GroupV2ChangeDetailType, GroupV2ChangeType } from './groups';
export type SmartContactRendererType = (conversationId: string) => FullJSXType;
export type StringRendererType = (
id: string,
i18n: LocalizerType,
components?: Array<FullJSXType> | ReplacementValuesType<FullJSXType>
) => FullJSXType;
export type RenderOptionsType = {
AccessControlEnum: typeof AccessControlClass.AccessRequired;
from?: string;
i18n: LocalizerType;
ourConversationId: string;
renderContact: SmartContactRendererType;
renderString: StringRendererType;
RoleEnum: typeof MemberClass.Role;
};
export function renderChange(
change: GroupV2ChangeType,
options: RenderOptionsType
) {
const { details, from } = change;
return details.map((detail: GroupV2ChangeDetailType) =>
renderChangeDetail(detail, {
...options,
from,
})
);
}
// tslint:disable-next-line cyclomatic-complexity max-func-body-length
export function renderChangeDetail(
detail: GroupV2ChangeDetailType,
options: RenderOptionsType
): FullJSXType {
const {
AccessControlEnum,
from,
i18n,
ourConversationId,
renderContact,
renderString,
RoleEnum,
} = options;
const fromYou = Boolean(from && from === ourConversationId);
if (detail.type === 'title') {
const { newTitle } = detail;
if (newTitle) {
if (fromYou) {
return renderString('GroupV2--title--change--you', i18n, [newTitle]);
} else if (from) {
return renderString('GroupV2--title--change--other', i18n, {
memberName: renderContact(from),
newTitle,
});
} else {
return renderString('GroupV2--title--change--unknown', i18n, [
newTitle,
]);
}
} else {
if (fromYou) {
return renderString('GroupV2--title--remove--you', i18n);
} else if (from) {
return renderString('GroupV2--title--remove--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--title--remove--unknown', i18n);
}
}
} else if (detail.type === 'avatar') {
if (detail.removed) {
if (fromYou) {
return renderString('GroupV2--avatar--remove--you', i18n);
} else if (from) {
return renderString('GroupV2--avatar--remove--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--avatar--remove--unknown', i18n);
}
} else {
if (fromYou) {
return renderString('GroupV2--avatar--change--you', i18n);
} else if (from) {
return renderString('GroupV2--avatar--change--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--avatar--change--unknown', i18n);
}
}
} else if (detail.type === 'access-attributes') {
const { newPrivilege } = detail;
if (newPrivilege === AccessControlEnum.ADMINISTRATOR) {
if (fromYou) {
return renderString('GroupV2--access-attributes--admins--you', i18n);
} else if (from) {
return renderString('GroupV2--access-attributes--admins--other', i18n, [
renderContact(from),
]);
} else {
return renderString(
'GroupV2--access-attributes--admins--unknown',
i18n
);
}
} else if (newPrivilege === AccessControlEnum.MEMBER) {
if (fromYou) {
return renderString('GroupV2--access-attributes--all--you', i18n);
} else if (from) {
return renderString('GroupV2--access-attributes--all--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--access-attributes--all--unknown', i18n);
}
} else {
throw new Error(
`access-attributes change type, privilege ${newPrivilege} is unknown`
);
}
} else if (detail.type === 'access-members') {
const { newPrivilege } = detail;
if (newPrivilege === AccessControlEnum.ADMINISTRATOR) {
if (fromYou) {
return renderString('GroupV2--access-members--admins--you', i18n);
} else if (from) {
return renderString('GroupV2--access-members--admins--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--access-members--admins--unknown', i18n);
}
} else if (newPrivilege === AccessControlEnum.MEMBER) {
if (fromYou) {
return renderString('GroupV2--access-members--all--you', i18n);
} else if (from) {
return renderString('GroupV2--access-members--all--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--access-members--all--unknown', i18n);
}
} else {
throw new Error(
`access-members change type, privilege ${newPrivilege} is unknown`
);
}
} else if (detail.type === 'member-add') {
const { conversationId } = detail;
const weAreJoiner = conversationId === ourConversationId;
if (weAreJoiner) {
if (fromYou) {
return renderString('GroupV2--member-add--you--you', i18n);
} else if (from) {
return renderString('GroupV2--member-add--you--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--member-add--you--unknown', i18n);
}
} else {
if (fromYou) {
return renderString('GroupV2--member-add--other--you', i18n, [
renderContact(conversationId),
]);
} else if (from) {
return renderString('GroupV2--member-add--other--other', i18n, {
adderName: renderContact(from),
addeeName: renderContact(conversationId),
});
} else {
return renderString('GroupV2--member-add--other--unknown', i18n, [
renderContact(conversationId),
]);
}
}
} else if (detail.type === 'member-add-from-invite') {
const { conversationId, inviter } = detail;
const weAreJoiner = conversationId === ourConversationId;
const weAreInviter = Boolean(inviter && inviter === ourConversationId);
if (weAreJoiner) {
return renderString('GroupV2--member-add--from-invite--you', i18n, [
renderContact(inviter),
]);
} else if (weAreInviter) {
return renderString('GroupV2--member-add--from-invite--from-you', i18n, [
renderContact(conversationId),
]);
} else {
return renderString('GroupV2--member-add--from-invite--other', i18n, {
inviteeName: renderContact(conversationId),
inviterName: renderContact(inviter),
});
}
} else if (detail.type === 'member-remove') {
const { conversationId } = detail;
const weAreLeaver = conversationId === ourConversationId;
if (weAreLeaver) {
if (fromYou) {
return renderString('GroupV2--member-remove--you--you', i18n);
} else if (from) {
return renderString('GroupV2--member-remove--you--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--member-remove--you--unknown', i18n);
}
} else {
if (fromYou) {
return renderString('GroupV2--member-remove--other--you', i18n, [
renderContact(conversationId),
]);
} else if (from && from === conversationId) {
return renderString('GroupV2--member-remove--other--self', i18n, [
renderContact(from),
]);
} else if (from) {
return renderString('GroupV2--member-remove--other--other', i18n, {
adminName: renderContact(from),
memberName: renderContact(conversationId),
});
} else {
return renderString('GroupV2--member-remove--other--unknown', i18n, [
renderContact(conversationId),
]);
}
}
} else if (detail.type === 'member-privilege') {
const { conversationId, newPrivilege } = detail;
const weAreMember = conversationId === ourConversationId;
if (newPrivilege === RoleEnum.ADMINISTRATOR) {
if (weAreMember) {
if (from) {
return renderString(
'GroupV2--member-privilege--promote--you--other',
i18n,
[renderContact(from)]
);
} else {
return renderString(
'GroupV2--member-privilege--promote--you--unknown',
i18n
);
}
} else {
if (fromYou) {
return renderString(
'GroupV2--member-privilege--promote--other--you',
i18n,
[renderContact(conversationId)]
);
} else if (from) {
return renderString(
'GroupV2--member-privilege--promote--other--other',
i18n,
{
adminName: renderContact(from),
memberName: renderContact(conversationId),
}
);
} else {
return renderString(
'GroupV2--member-privilege--promote--other--unknown',
i18n,
[renderContact(conversationId)]
);
}
}
} else if (newPrivilege === RoleEnum.DEFAULT) {
if (weAreMember) {
if (from) {
return renderString(
'GroupV2--member-privilege--demote--you--other',
i18n,
[renderContact(from)]
);
} else {
return renderString(
'GroupV2--member-privilege--demote--you--unknown',
i18n
);
}
} else {
if (fromYou) {
return renderString(
'GroupV2--member-privilege--demote--other--you',
i18n,
[renderContact(conversationId)]
);
} else if (from) {
return renderString(
'GroupV2--member-privilege--demote--other--other',
i18n,
{
adminName: renderContact(from),
memberName: renderContact(conversationId),
}
);
} else {
return renderString(
'GroupV2--member-privilege--demote--other--unknown',
i18n,
[renderContact(conversationId)]
);
}
}
} else {
throw new Error(
`member-privilege change type, privilege ${newPrivilege} is unknown`
);
}
} else if (detail.type === 'pending-add-one') {
const { conversationId } = detail;
const weAreInvited = conversationId === ourConversationId;
if (weAreInvited) {
if (from) {
return renderString('GroupV2--pending-add--one--you--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--pending-add--one--you--unknown', i18n);
}
} else {
if (fromYou) {
return renderString('GroupV2--pending-add--one--other--you', i18n, [
renderContact(conversationId),
]);
} else if (from) {
return renderString('GroupV2--pending-add--one--other--other', i18n, [
renderContact(from),
]);
} else {
return renderString('GroupV2--pending-add--one--other--unknown', i18n);
}
}
} else if (detail.type === 'pending-add-many') {
const { count } = detail;
if (fromYou) {
return renderString('GroupV2--pending-add--many--you', i18n, [
count.toString(),
]);
} else if (from) {
return renderString('GroupV2--pending-add--many--other', i18n, {
memberName: renderContact(from),
count: count.toString(),
});
} else {
return renderString('GroupV2--pending-add--many--unknown', i18n, [
count.toString(),
]);
}
} else if (detail.type === 'pending-remove-one') {
const { inviter, conversationId } = detail;
const weAreInviter = Boolean(inviter && inviter === ourConversationId);
const sentByInvited = Boolean(from && from === conversationId);
if (weAreInviter) {
if (inviter && sentByInvited) {
return renderString('GroupV2--pending-remove--decline--you', i18n, [
renderContact(conversationId),
]);
} else if (fromYou) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from-you--one--you',
i18n,
[renderContact(conversationId)]
);
} else if (from) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from-you--one--other',
i18n,
{
adminName: renderContact(from),
inviteeName: renderContact(conversationId),
}
);
} else {
return renderString(
'GroupV2--pending-remove--revoke-invite-from-you--one--unknown',
i18n,
[renderContact(conversationId)]
);
}
} else if (sentByInvited) {
if (inviter) {
return renderString('GroupV2--pending-remove--decline--other', i18n, [
renderContact(inviter),
]);
} else {
return renderString('GroupV2--pending-remove--decline--unknown', i18n);
}
} else if (inviter) {
if (fromYou) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from--one--you',
i18n,
[renderContact(inviter)]
);
} else if (from) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from--one--other',
i18n,
{
adminName: renderContact(from),
memberName: renderContact(inviter),
}
);
} else {
return renderString(
'GroupV2--pending-remove--revoke-invite-from--one--unknown',
i18n,
[renderContact(inviter)]
);
}
} else {
if (fromYou) {
return renderString('GroupV2--pending-remove--revoke--one--you', i18n);
} else if (from) {
return renderString(
'GroupV2--pending-remove--revoke--one--other',
i18n,
[renderContact(from)]
);
} else {
return renderString(
'GroupV2--pending-remove--revoke--one--unknown',
i18n
);
}
}
} else if (detail.type === 'pending-remove-many') {
const { count, inviter } = detail;
const weAreInviter = Boolean(inviter && inviter === ourConversationId);
if (weAreInviter) {
if (fromYou) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from-you--many--you',
i18n,
[count.toString()]
);
} else if (from) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from-you--many--other',
i18n,
{
adminName: renderContact(from),
count: count.toString(),
}
);
} else {
return renderString(
'GroupV2--pending-remove--revoke-invite-from-you--many--unknown',
i18n,
[count.toString()]
);
}
} else if (inviter) {
if (fromYou) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from--many--you',
i18n,
{
count: count.toString(),
memberName: renderContact(inviter),
}
);
} else if (from) {
return renderString(
'GroupV2--pending-remove--revoke-invite-from--many--other',
i18n,
{
adminName: renderContact(from),
count: count.toString(),
memberName: renderContact(inviter),
}
);
} else {
return renderString(
'GroupV2--pending-remove--revoke-invite-from--many--unknown',
i18n,
{
count: count.toString(),
memberName: renderContact(inviter),
}
);
}
} else {
if (fromYou) {
return renderString(
'GroupV2--pending-remove--revoke--many--you',
i18n,
[count.toString()]
);
} else if (from) {
return renderString(
'GroupV2--pending-remove--revoke--many--other',
i18n,
{
memberName: renderContact(from),
count: count.toString(),
}
);
} else {
return renderString(
'GroupV2--pending-remove--revoke--many--unknown',
i18n,
[count.toString()]
);
}
}
} else {
throw missingCaseError(detail);
}
}

2296
ts/groups.ts Normal file

File diff suppressed because it is too large Load diff

88
ts/model-types.d.ts vendored
View file

@ -1,5 +1,6 @@
import * as Backbone from 'backbone';
import { GroupV2ChangeType } from './groups';
import { LocalizerType } from './types/Util';
import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors';
@ -26,7 +27,24 @@ type TaskResultType = any;
type MessageAttributesType = {
id: string;
serverTimestamp: number;
type?: string;
expirationTimerUpdate?: {
expireTimer: number;
source?: string;
sourceUuid?: string;
};
// Legacy fields for timer update notification only
flags?: number;
groupV2Change?: GroupV2ChangeType;
// Required. Used to sort messages in the database for the conversation timeline.
received_at?: number;
// More of a legacy feature, needed as we were updating the schema of messages in the
// background, when we were still in IndexedDB, before attachments had gone to disk
// We set this so that the idle message upgrade process doesn't pick this message up
schemaVersion: number;
serverTimestamp?: number;
sourceUuid?: string;
};
declare class MessageModelType extends Backbone.Model<MessageAttributesType> {
@ -49,27 +67,71 @@ type ConversationTypeType = 'private' | 'group';
type ConversationAttributesType = {
id: string;
uuid?: string;
e164?: string;
type: ConversationTypeType;
timestamp: number;
// Shared fields
active_at?: number | null;
draft?: string;
groupId?: string;
isArchived?: boolean;
lastMessage?: string;
members?: Array<string>;
name?: string;
needsStorageServiceSync?: boolean;
needsVerification?: boolean;
profileFamilyName?: string | null;
profileKey?: string | null;
profileName?: string | null;
profileSharing: boolean;
storageID?: string;
storageUnknownFields: string;
type: ConversationTypeType;
unreadCount?: number;
verified?: number;
version: number;
// Private core info
uuid?: string;
e164?: string;
// Private other fields
profileFamilyName?: string | null;
profileKey?: string | null;
profileName?: string | null;
verified?: number;
// Group-only
groupId?: string;
left: boolean;
groupVersion?: number;
// GroupV1 only
members?: Array<string>;
// GroupV2 core info
masterKey?: string;
secretParams?: string;
publicParams?: string;
revision?: number;
// GroupV2 other fields
accessControl?: {
attributes: number;
members: number;
};
avatar?: {
url: string;
path: string;
hash: string;
};
expireTimer?: number;
membersV2?: Array<GroupV2MemberType>;
pendingMembersV2?: Array<GroupV2PendingMemberType>;
};
export type GroupV2MemberType = {
conversationId: string;
role: number;
joinedAtVersion: number;
};
export type GroupV2PendingMemberType = {
addedByUserId: string;
conversationId: string;
timestamp: number;
};
type VerificationOptions = {
@ -113,6 +175,12 @@ export declare class ConversationModelType extends Backbone.Model<
isMe(): boolean;
isPrivate(): boolean;
isVerified(): boolean;
maybeRepairGroupV2(data: {
masterKey: string;
secretParams: string;
publicParams: string;
}): void;
queueJob(job: () => Promise<void>): Promise<void>;
safeGetVerified(): Promise<number>;
setArchived(isArchived: boolean): void;
setProfileKey(

View file

@ -0,0 +1,212 @@
import { last, sortBy } from 'lodash';
import { AuthCredentialResponse } from 'zkgroup';
import {
base64ToCompatArray,
compatArrayToBase64,
getClientZkAuthOperations,
} from '../util/zkgroup';
import { GroupCredentialType } from '../textsecure/WebAPI';
export const GROUP_CREDENTIALS_KEY = 'groupCredentials';
type CredentialsDataType = Array<GroupCredentialType>;
type RequestDatesType = {
startDay: number;
endDay: number;
};
type NextCredentialsType = {
today: GroupCredentialType;
tomorrow: GroupCredentialType;
};
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
function getTodayInEpoch() {
return Math.floor(Date.now() / DAY);
}
async function sleep(ms: number) {
// tslint:disable-next-line no-string-based-set-timeout
return new Promise(resolve => setTimeout(resolve, ms));
}
let started = false;
export async function initializeGroupCredentialFetcher(): Promise<void> {
if (started) {
return;
}
window.log.info('initializeGroupCredentialFetcher: starting...');
started = true;
// Because we fetch eight days of credentials at a time, we really only need to run
// this about once a week. But there's no problem running it more often; it will do
// nothing if no new credentials are needed, and will only request needed credentials.
await runWithRetry(maybeFetchNewCredentials, { scheduleAnother: 4 * HOUR });
}
type BackoffType = {
[key: number]: number | undefined;
max: number;
};
const BACKOFF: BackoffType = {
0: SECOND,
1: 5 * SECOND,
2: 30 * SECOND,
3: 2 * MINUTE,
max: 5 * MINUTE,
};
export async function runWithRetry(
fn: () => Promise<void>,
options: { scheduleAnother?: number } = {}
): Promise<void> {
let count = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
count += 1;
// eslint-disable-next-line no-await-in-loop
await fn();
return;
} catch (error) {
const wait = BACKOFF[count] || BACKOFF.max;
window.log.info(
`runWithRetry: ${fn.name} failed. Waiting ${wait}ms for retry. Error: ${error.stack}`
);
// eslint-disable-next-line no-await-in-loop
await sleep(wait);
}
}
// It's important to schedule our next run here instead of the level above; otherwise we
// could end up with multiple endlessly-retrying runs.
const duration = options.scheduleAnother;
if (duration) {
window.log.info(
`runWithRetry: scheduling another run with a setTimeout duration of ${duration}ms`
);
setTimeout(async () => runWithRetry(fn, options), duration);
}
}
// In cases where we are at a day boundary, we might need to use tomorrow in a retry
export function getCredentialsForToday(
data: CredentialsDataType | undefined
): NextCredentialsType {
if (!data) {
throw new Error('getCredentialsForToday: No credentials fetched!');
}
const todayInEpoch = getTodayInEpoch();
const todayIndex = data.findIndex(
(item: GroupCredentialType) => item.redemptionTime === todayInEpoch
);
if (todayIndex < 0) {
throw new Error(
'getCredentialsForToday: Cannot find credentials for today'
);
}
return {
today: data[todayIndex],
tomorrow: data[todayIndex + 1],
};
}
export async function maybeFetchNewCredentials(): Promise<void> {
const uuid = window.textsecure.storage.user.getUuid();
if (!uuid) {
window.log.info('maybeFetchCredentials: no UUID, returning early');
return;
}
const previous: CredentialsDataType | undefined = window.storage.get(
GROUP_CREDENTIALS_KEY
);
const requestDates = getDatesForRequest(previous);
if (!requestDates) {
window.log.info('maybeFetchCredentials: no new credentials needed');
return;
}
const accountManager = window.getAccountManager();
if (!accountManager) {
window.log.info('maybeFetchCredentials: unable to get AccountManager');
return;
}
const { startDay, endDay } = requestDates;
window.log.info(
`maybeFetchCredentials: fetching credentials for ${startDay} through ${endDay}`
);
const serverPublicParamsBase64 = window.getServerPublicParams();
const clientZKAuthOperations = getClientZkAuthOperations(
serverPublicParamsBase64
);
const newCredentials = sortCredentials(
await accountManager.getGroupCredentials(startDay, endDay)
).map((item: GroupCredentialType) => {
const authCredential = clientZKAuthOperations.receiveAuthCredential(
uuid,
item.redemptionTime,
new AuthCredentialResponse(base64ToCompatArray(item.credential))
);
const credential = compatArrayToBase64(authCredential.serialize());
return {
redemptionTime: item.redemptionTime,
credential,
};
});
const todayInEpoch = getTodayInEpoch();
const previousCleaned = previous
? previous.filter(
(item: GroupCredentialType) => item.redemptionTime >= todayInEpoch
)
: [];
const finalCredentials = [...previousCleaned, ...newCredentials];
window.log.info('maybeFetchCredentials: Saving new credentials...');
// Note: we don't wait for this to finish
window.storage.put(GROUP_CREDENTIALS_KEY, finalCredentials);
window.log.info('maybeFetchCredentials: Save complete.');
}
export function getDatesForRequest(
data?: CredentialsDataType
): RequestDatesType | undefined {
const todayInEpoch = getTodayInEpoch();
const oneWeekOut = todayInEpoch + 7;
const lastCredential = last(data);
if (!lastCredential || lastCredential.redemptionTime < todayInEpoch) {
return {
startDay: todayInEpoch,
endDay: oneWeekOut,
};
}
if (lastCredential.redemptionTime >= oneWeekOut) {
return undefined;
}
return {
startDay: lastCredential.redemptionTime + 1,
endDay: oneWeekOut,
};
}
export function sortCredentials(
data: CredentialsDataType
): CredentialsDataType {
return sortBy(data, (item: GroupCredentialType) => item.redemptionTime);
}

View file

@ -22,9 +22,11 @@ import {
mergeAccountRecord,
mergeContactRecord,
mergeGroupV1Record,
mergeGroupV2Record,
toAccountRecord,
toContactRecord,
toGroupV1Record,
toGroupV2Record,
} from './storageRecordOps';
const {
@ -128,6 +130,11 @@ async function generateManifest(
// eslint-disable-next-line no-await-in-loop
storageRecord.contact = await toContactRecord(conversation);
identifier.type = ITEM_TYPE.CONTACT;
} else if ((conversation.get('groupVersion') || 0) > 1) {
storageRecord = new window.textsecure.protobuf.StorageRecord();
// eslint-disable-next-line no-await-in-loop
storageRecord.groupV1 = await toGroupV2Record(conversation);
identifier.type = ITEM_TYPE.GROUPV2;
} else {
storageRecord = new window.textsecure.protobuf.StorageRecord();
// eslint-disable-next-line no-await-in-loop
@ -389,7 +396,8 @@ async function fetchManifest(
if (err.code === 404) {
await createNewManifest();
return;
} else if (err.code === 204) {
}
if (err.code === 204) {
// noNewerManifest we're ok
return;
}
@ -429,6 +437,12 @@ async function mergeRecord(
hasConflict = await mergeContactRecord(storageID, storageRecord.contact);
} else if (itemType === ITEM_TYPE.GROUPV1 && storageRecord.groupV1) {
hasConflict = await mergeGroupV1Record(storageID, storageRecord.groupV1);
} else if (
window.GV2 &&
itemType === ITEM_TYPE.GROUPV2 &&
storageRecord.groupV2
) {
hasConflict = await mergeGroupV2Record(storageID, storageRecord.groupV2);
} else if (itemType === ITEM_TYPE.ACCOUNT && storageRecord.account) {
hasConflict = await mergeAccountRecord(storageID, storageRecord.account);
} else {
@ -592,9 +606,9 @@ async function processManifest(
);
return true;
} else {
consecutiveConflicts = 0;
}
consecutiveConflicts = 0;
} catch (err) {
window.log.error(
`storageService.processManifest: failed! ${

View file

@ -1,5 +1,5 @@
/* tslint:disable no-backbone-get-set-outside-model */
import _ from 'lodash';
import { isEqual, isNumber } from 'lodash';
import {
arrayBufferToBase64,
@ -11,12 +11,18 @@ import {
AccountRecordClass,
ContactRecordClass,
GroupV1RecordClass,
GroupV2RecordClass,
} from '../textsecure.d';
import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups';
import { ConversationModelType } from '../model-types.d';
const { updateConversation } = dataInterface;
type RecordClass = AccountRecordClass | ContactRecordClass | GroupV1RecordClass;
type RecordClass =
| AccountRecordClass
| ContactRecordClass
| GroupV1RecordClass
| GroupV2RecordClass;
function toRecordVerified(verified: number): number {
const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus;
@ -147,6 +153,24 @@ export async function toGroupV1Record(
return groupV1Record;
}
export async function toGroupV2Record(
conversation: ConversationModelType
): Promise<GroupV2RecordClass> {
const groupV2Record = new window.textsecure.protobuf.GroupV2Record();
const masterKey = conversation.get('masterKey');
if (masterKey !== undefined) {
groupV2Record.masterKey = base64ToArrayBuffer(masterKey);
}
groupV2Record.blocked = conversation.isBlocked();
groupV2Record.whitelisted = Boolean(conversation.get('profileSharing'));
groupV2Record.archived = Boolean(conversation.get('isArchived'));
applyUnknownFields(groupV2Record, conversation);
return groupV2Record;
}
type MessageRequestCapableRecord = ContactRecordClass | GroupV1RecordClass;
function applyMessageRequestState(
@ -183,7 +207,7 @@ function doesRecordHavePendingChanges(
): boolean {
const shouldSync = Boolean(conversation.get('needsStorageServiceSync'));
const hasConflict = !_.isEqual(mergedRecord, serviceRecord);
const hasConflict = !isEqual(mergedRecord, serviceRecord);
if (shouldSync && !hasConflict) {
conversation.set({ needsStorageServiceSync: false });
@ -240,6 +264,81 @@ export async function mergeGroupV1Record(
return hasPendingChanges;
}
export async function mergeGroupV2Record(
storageID: string,
groupV2Record: GroupV2RecordClass
): Promise<boolean> {
window.log.info(`storageService.mergeGroupV2Record: merging ${storageID}`);
if (!groupV2Record.masterKey) {
window.log.info(
`storageService.mergeGroupV2Record: no master key for ${storageID}`
);
return false;
}
const masterKeyBuffer = groupV2Record.masterKey.toArrayBuffer();
const groupFields = deriveGroupFields(masterKeyBuffer);
const groupId = arrayBufferToBase64(groupFields.id);
const masterKey = arrayBufferToBase64(masterKeyBuffer);
const secretParams = arrayBufferToBase64(groupFields.secretParams);
const publicParams = arrayBufferToBase64(groupFields.publicParams);
const now = Date.now();
const conversationId = window.ConversationController.ensureGroup(groupId, {
// We want this conversation to show in the left pane when we first learn about it
active_at: now,
timestamp: now,
// Basic GroupV2 data
groupVersion: 2,
masterKey,
secretParams,
publicParams,
});
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`storageService.mergeGroupV2Record: No conversation for groupv2(${groupId})`
);
}
conversation.maybeRepairGroupV2({
masterKey,
secretParams,
publicParams,
});
conversation.set({
isArchived: Boolean(groupV2Record.archived),
storageID,
});
applyMessageRequestState(groupV2Record, conversation);
addUnknownFields(groupV2Record, conversation);
const hasPendingChanges = doesRecordHavePendingChanges(
await toGroupV2Record(conversation),
groupV2Record,
conversation
);
updateConversation(conversation.attributes);
const isFirstSync = !isNumber(window.storage.get('manifestVersion'));
const dropInitialJoinMessage = isFirstSync;
// tslint:disable-next-line no-floating-promises
waitThenMaybeUpdateGroup({
conversation,
dropInitialJoinMessage,
});
window.log.info(`storageService.mergeGroupV2Record: merged ${storageID}`);
return hasPendingChanges;
}
export async function mergeContactRecord(
storageID: string,
contactRecord: ContactRecordClass

View file

@ -10,6 +10,7 @@ import { redactAll } from '../../js/modules/privacy';
import { remove as removeUserConfig } from '../../app/user_config';
import { combineNames } from '../util/combineNames';
import { GroupV2MemberType } from '../model-types.d';
import { LocaleMessagesType } from '../types/I18N';
import pify from 'pify';
@ -2070,6 +2071,7 @@ async function saveConversation(
groupId,
id,
members,
membersV2,
name,
profileFamilyName,
profileName,
@ -2077,6 +2079,13 @@ async function saveConversation(
uuid,
} = data;
// prettier-ignore
const membersList = membersV2
? membersV2.map((item: GroupV2MemberType) => item.conversationId).join(' ')
: members
? members.join(' ')
: null;
await instance.run(
`INSERT INTO conversations (
id,
@ -2119,7 +2128,7 @@ async function saveConversation(
$active_at: active_at,
$type: type,
$members: members ? members.join(' ') : null,
$members: membersList,
$name: name,
$profileName: profileName,
$profileFamilyName: profileFamilyName,
@ -2156,6 +2165,7 @@ async function updateConversation(data: ConversationType) {
active_at,
type,
members,
membersV2,
name,
profileName,
profileFamilyName,
@ -2163,6 +2173,13 @@ async function updateConversation(data: ConversationType) {
uuid,
} = data;
// prettier-ignore
const membersList = membersV2
? membersV2.map((item: GroupV2MemberType) => item.conversationId).join(' ')
: members
? members.join(' ')
: null;
await db.run(
`UPDATE conversations SET
json = $json,
@ -2187,7 +2204,7 @@ async function updateConversation(data: ConversationType) {
$active_at: active_at,
$type: type,
$members: members ? members.join(' ') : null,
$members: membersList,
$name: name,
$profileName: profileName,
$profileFamilyName: profileFamilyName,

View file

@ -1009,6 +1009,19 @@ export function reducer(
id
);
let metrics;
if (messageIds.length === 0) {
metrics = {
totalUnread: 0,
};
} else {
metrics = {
...existingConversation.metrics,
oldest,
newest,
};
}
return {
...state,
messagesLookup: omit(messagesLookup, id),
@ -1017,11 +1030,7 @@ export function reducer(
...existingConversation,
messageIds,
heightChangeMessageIds,
metrics: {
...existingConversation.metrics,
oldest,
newest,
},
metrics,
},
},
};

View file

@ -0,0 +1,32 @@
import * as React from 'react';
import { useSelector } from 'react-redux';
import { StateType } from '../reducer';
import { ContactName } from '../../components/conversation/ContactName';
import { getIntl } from '../selectors/user';
import {
GetConversationByIdType,
getConversationSelector,
} from '../selectors/conversations';
import { LocalizerType } from '../../types/Util';
type ExternalProps = {
conversationId: string;
};
export const SmartContactName = (props: ExternalProps) => {
const { conversationId } = props;
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const getConversation = useSelector<StateType, GetConversationByIdType>(
getConversationSelector
);
const conversation = getConversation(conversationId);
if (!conversation) {
throw new Error(`Conversation id ${conversationId} not found!`);
}
return <ContactName i18n={i18n} {...conversation} />;
};

View file

@ -1,3 +1,4 @@
import React from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
@ -10,11 +11,21 @@ import {
getSelectedMessage,
} from '../selectors/conversations';
import { SmartContactName } from './ContactName';
type ExternalProps = {
id: string;
conversationId: string;
};
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredSmartContactName = SmartContactName as any;
function renderContact(conversationId: string): JSX.Element {
return <FilteredSmartContactName conversationId={conversationId} />;
}
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, conversationId } = props;
@ -29,6 +40,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
id,
conversationId,
isSelected,
renderContact,
i18n: getIntl(state),
};
};

307
ts/textsecure.d.ts vendored
View file

@ -6,6 +6,7 @@ import {
} from './libsignal.d';
import Crypto from './textsecure/Crypto';
import MessageReceiver from './textsecure/MessageReceiver';
import MessageSender from './textsecure/SendMessage';
import EventTarget from './textsecure/EventTarget';
import { ByteBufferClass } from './window.d';
import SendMessage, { SendOptionsType } from './textsecure/SendMessage';
@ -75,11 +76,7 @@ export type TextSecureType = {
remove: (key: string | Array<string>) => Promise<void>;
protocol: StorageProtocolType;
};
messageReceiver: {
downloadAttachment: (
attachment: AttachmentPointerClass
) => Promise<DownloadAttachmentType>;
};
messageReceiver: MessageReceiver;
messaging?: SendMessage;
protobuf: ProtobufCollectionType;
utils: typeof utils;
@ -145,7 +142,44 @@ export type StorageProtocolType = StorageType & {
// Protobufs
type StorageServiceProtobufTypes = {
type DeviceMessagesProtobufTypes = {
ProvisioningUuid: typeof ProvisioningUuidClass;
ProvisionEnvelope: typeof ProvisionEnvelopeClass;
ProvisionMessage: typeof ProvisionMessageClass;
};
type DeviceNameProtobufTypes = {
DeviceName: typeof DeviceNameClass;
};
type GroupsProtobufTypes = {
AvatarUploadAttributes: typeof AvatarUploadAttributesClass;
Member: typeof MemberClass;
PendingMember: typeof PendingMemberClass;
AccessControl: typeof AccessControlClass;
Group: typeof GroupClass;
GroupChange: typeof GroupChangeClass;
GroupChanges: typeof GroupChangesClass;
GroupAttributeBlob: typeof GroupAttributeBlobClass;
};
type SignalServiceProtobufTypes = {
AttachmentPointer: typeof AttachmentPointerClass;
ContactDetails: typeof ContactDetailsClass;
Content: typeof ContentClass;
DataMessage: typeof DataMessageClass;
Envelope: typeof EnvelopeClass;
GroupContext: typeof GroupContextClass;
GroupContextV2: typeof GroupContextV2Class;
GroupDetails: typeof GroupDetailsClass;
NullMessage: typeof NullMessageClass;
ReceiptMessage: typeof ReceiptMessageClass;
SyncMessage: typeof SyncMessageClass;
TypingMessage: typeof TypingMessageClass;
Verified: typeof VerifiedClass;
};
type SignalStorageProtobufTypes = {
AccountRecord: typeof AccountRecordClass;
ContactRecord: typeof ContactRecordClass;
GroupV1Record: typeof GroupV1RecordClass;
@ -159,35 +193,252 @@ type StorageServiceProtobufTypes = {
WriteOperation: typeof WriteOperationClass;
};
type ProtobufCollectionType = StorageServiceProtobufTypes & {
AttachmentPointer: typeof AttachmentPointerClass;
ContactDetails: typeof ContactDetailsClass;
Content: typeof ContentClass;
DataMessage: typeof DataMessageClass;
DeviceName: typeof DeviceNameClass;
Envelope: typeof EnvelopeClass;
GroupContext: typeof GroupContextClass;
GroupContextV2: typeof GroupContextV2Class;
GroupDetails: typeof GroupDetailsClass;
NullMessage: typeof NullMessageClass;
ProvisioningUuid: typeof ProvisioningUuidClass;
ProvisionEnvelope: typeof ProvisionEnvelopeClass;
ProvisionMessage: typeof ProvisionMessageClass;
ReceiptMessage: typeof ReceiptMessageClass;
SyncMessage: typeof SyncMessageClass;
TypingMessage: typeof TypingMessageClass;
Verified: typeof VerifiedClass;
type SubProtocolProtobufTypes = {
WebSocketMessage: typeof WebSocketMessageClass;
WebSocketRequestMessage: typeof WebSocketRequestMessageClass;
WebSocketResponseMessage: typeof WebSocketResponseMessageClass;
};
type ProtobufCollectionType = DeviceMessagesProtobufTypes &
DeviceNameProtobufTypes &
GroupsProtobufTypes &
SignalServiceProtobufTypes &
SignalStorageProtobufTypes &
SubProtocolProtobufTypes;
// Note: there are a lot of places in the code that overwrite a field like this
// with a type that the app can use. Being more rigorous with these
// types would require code changes, out of scope for now.
type ProtoBinaryType = any;
type ProtoBigNumberType = any;
// Groups.proto
export declare class AvatarUploadAttributesClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => AvatarUploadAttributesClass;
key?: string;
credential?: string;
acl?: string;
algorithm?: string;
date?: string;
policy?: string;
signature?: string;
}
export declare class MemberClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => MemberClass;
userId?: ProtoBinaryType;
role?: MemberRoleEnum;
profileKey?: ProtoBinaryType;
presentation?: ProtoBinaryType;
joinedAtVersion?: number;
}
type MemberRoleEnum = number;
// Note: we need to use namespaces to express nested classes in Typescript
export declare namespace MemberClass {
class Role {
static UNKNOWN: number;
static DEFAULT: number;
static ADMINISTRATOR: number;
}
}
export declare class PendingMemberClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => PendingMemberClass;
member?: MemberClass;
addedByUserId?: ProtoBinaryType;
timestamp?: ProtoBigNumberType;
}
type AccessRequiredEnum = number;
export declare class AccessControlClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => AccessControlClass;
attributes?: AccessRequiredEnum;
members?: AccessRequiredEnum;
}
// Note: we need to use namespaces to express nested classes in Typescript
export declare namespace AccessControlClass {
class AccessRequired {
static UNKNOWN: number;
static MEMBER: number;
static ADMINISTRATOR: number;
}
}
export declare class GroupClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => GroupClass;
toArrayBuffer: () => ArrayBuffer;
publicKey?: ProtoBinaryType;
title?: ProtoBinaryType;
avatar?: string;
disappearingMessagesTimer?: ProtoBinaryType;
accessControl?: AccessControlClass;
version?: number;
members?: Array<MemberClass>;
pendingMembers?: Array<PendingMemberClass>;
}
export declare class GroupChangeClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => GroupChangeClass;
actions?: ProtoBinaryType;
serverSignature?: ProtoBinaryType;
}
// Note: we need to use namespaces to express nested classes in Typescript
export declare namespace GroupChangeClass {
class Actions {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => Actions;
toArrayBuffer: () => ArrayBuffer;
sourceUuid?: ProtoBinaryType;
version?: number;
addMembers?: Array<GroupChangeClass.Actions.AddMemberAction>;
deleteMembers?: Array<GroupChangeClass.Actions.DeleteMemberAction>;
modifyMemberRoles?: Array<GroupChangeClass.Actions.ModifyMemberRoleAction>;
modifyMemberProfileKeys?: Array<
GroupChangeClass.Actions.ModifyMemberProfileKeyAction
>;
addPendingMembers?: Array<GroupChangeClass.Actions.AddPendingMemberAction>;
deletePendingMembers?: Array<
GroupChangeClass.Actions.DeletePendingMemberAction
>;
promotePendingMembers?: Array<
GroupChangeClass.Actions.PromotePendingMemberAction
>;
modifyTitle?: GroupChangeClass.Actions.ModifyTitleAction;
modifyAvatar?: GroupChangeClass.Actions.ModifyAvatarAction;
modifyDisappearingMessagesTimer?: GroupChangeClass.Actions.ModifyDisappearingMessagesTimerAction;
modifyAttributesAccess?: GroupChangeClass.Actions.ModifyAttributesAccessControlAction;
modifyMemberAccess?: GroupChangeClass.Actions.ModifyMembersAccessControlAction;
}
}
// Note: we need to use namespaces to express nested classes in Typescript
export declare namespace GroupChangeClass.Actions {
class AddMemberAction {
added?: MemberClass;
}
class DeleteMemberAction {
deletedUserId?: ProtoBinaryType;
}
class ModifyMemberRoleAction {
userId?: ProtoBinaryType;
role?: MemberRoleEnum;
}
class ModifyMemberProfileKeyAction {
presentation?: ProtoBinaryType;
// The result of decryption
profileKey: ArrayBuffer;
uuid: string;
}
class AddPendingMemberAction {
added?: PendingMemberClass;
}
class DeletePendingMemberAction {
deletedUserId?: ProtoBinaryType;
}
class PromotePendingMemberAction {
presentation?: ProtoBinaryType;
// The result of decryption
profileKey: ArrayBuffer;
uuid: string;
}
class ModifyTitleAction {
title?: ProtoBinaryType;
}
class ModifyAvatarAction {
avatar?: string;
}
class ModifyDisappearingMessagesTimerAction {
timer?: ProtoBinaryType;
}
class ModifyAttributesAccessControlAction {
attributesAccess?: AccessRequiredEnum;
}
class ModifyMembersAccessControlAction {
membersAccess?: AccessRequiredEnum;
}
}
export declare class GroupChangesClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => GroupChangesClass;
groupChanges?: Array<GroupChangesClass.GroupChangeState>;
}
// Note: we need to use namespaces to express nested classes in Typescript
export declare namespace GroupChangesClass {
class GroupChangeState {
groupChange?: GroupChangeClass;
groupState?: GroupClass;
}
}
export declare class GroupAttributeBlobClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
encoding?: string
) => GroupAttributeBlobClass;
toArrayBuffer(): ArrayBuffer;
title?: string;
avatar?: ProtoBinaryType;
disappearingMessagesDuration?: number;
// Note: this isn't part of the proto, but our protobuf library tells us which
// field has been set with this prop.
content: 'title' | 'avatar' | 'disappearingMessagesDuration';
}
// Previous protos
export declare class AttachmentPointerClass {
static decode: (
data: ArrayBuffer | ByteBufferClass,
@ -435,6 +686,11 @@ export declare class GroupContextV2Class {
masterKey?: ProtoBinaryType;
revision?: number;
groupChange?: ProtoBinaryType;
// Note: these additional properties are added in the course of processing
id?: string;
secretParams?: string;
publicParams?: string;
}
// Note: we need to use namespaces to express nested classes in Typescript
@ -674,7 +930,7 @@ export declare class GroupV2RecordClass {
) => GroupV2RecordClass;
toArrayBuffer: () => ArrayBuffer;
masterKey?: ByteBufferClass | null;
masterKey?: ProtoBinaryType | null;
blocked?: boolean | null;
whitelisted?: boolean | null;
archived?: boolean | null;
@ -754,6 +1010,7 @@ export declare namespace SyncMessageClass {
unidentifiedDeliveryIndicators?: boolean;
typingIndicators?: boolean;
linkPreviews?: boolean;
provisioningVersion?: number;
}
class Contacts {
blob?: AttachmentPointerClass;

View file

@ -610,6 +610,11 @@ export default class AccountManager extends EventTarget {
store.clearSessionStore(),
]);
}
async getGroupCredentials(startDay: number, endDay: number) {
return this.server.getGroupCredentials(startDay, endDay);
}
// Takes the same object returned by generateKeys
async confirmKeys(keys: GeneratedKeysType) {
const store = window.textsecure.storage.protocol;

View file

@ -30,6 +30,8 @@ import {
VerifiedClass,
} from '../textsecure.d';
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
const RETRY_TIMEOUT = 2 * 60 * 1000;
declare global {
@ -435,6 +437,9 @@ class MessageReceiverInner extends EventTarget {
return promise;
}
hasEmptied(): boolean {
return Boolean(this.isEmptied);
}
onEmpty() {
const emitEmpty = () => {
window.log.info("MessageReceiver: emitting 'empty' event");
@ -1070,14 +1075,6 @@ class MessageReceiverInner extends EventTarget {
throw new Error('MessageReceiver.handleSentMessage: message was falsey!');
}
if (msg.groupV2) {
window.log.warn(
'MessageReceiver.handleSentMessage: Dropping GroupsV2 message'
);
this.removeFromCache(envelope);
return;
}
let p: Promise<any> = Promise.resolve();
// eslint-disable-next-line no-bitwise
if (
@ -1094,7 +1091,8 @@ class MessageReceiverInner extends EventTarget {
}
return p.then(async () =>
this.processDecrypted(envelope, msg).then(message => {
const groupId = message.group && message.group.id;
// prettier-ignore
const groupId = this.getGroupId(message);
const isBlocked = this.isGroupBlocked(groupId);
const { source, sourceUuid } = envelope;
const ourE164 = window.textsecure.storage.user.getNumber();
@ -1103,7 +1101,8 @@ class MessageReceiverInner extends EventTarget {
(source && ourE164 && source === ourE164) ||
(sourceUuid && ourUuid && sourceUuid === ourUuid);
const isLeavingGroup = Boolean(
message.group &&
!message.groupV2 &&
message.group &&
message.group.type ===
window.textsecure.protobuf.GroupContext.Type.QUIT
);
@ -1148,14 +1147,16 @@ class MessageReceiverInner extends EventTarget {
);
}
if (msg.groupV2) {
window.log.warn(
'MessageReceiver.handleDataMessage: Dropping GroupsV2 message'
);
if (!window.GV2 && msg.groupV2) {
this.removeFromCache(envelope);
window.log.info(
'MessageReceiver.handleDataMessage: dropping GroupV2 message'
);
return;
}
this.deriveGroupsV2Data(msg);
if (
msg.flags &&
msg.flags & window.textsecure.protobuf.DataMessage.Flags.END_SESSION
@ -1180,7 +1181,8 @@ class MessageReceiverInner extends EventTarget {
return p.then(async () =>
this.processDecrypted(envelope, msg).then(message => {
const groupId = message.group && message.group.id;
// prettier-ignore
const groupId = this.getGroupId(message);
const isBlocked = this.isGroupBlocked(groupId);
const { source, sourceUuid } = envelope;
const ourE164 = window.textsecure.storage.user.getNumber();
@ -1189,7 +1191,8 @@ class MessageReceiverInner extends EventTarget {
(source && ourE164 && source === ourE164) ||
(sourceUuid && ourUuid && sourceUuid === ourUuid);
const isLeavingGroup = Boolean(
message.group &&
!message.groupV2 &&
message.group &&
message.group.type ===
window.textsecure.protobuf.GroupContext.Type.QUIT
);
@ -1336,23 +1339,26 @@ class MessageReceiverInner extends EventTarget {
}
}
const { groupId, timestamp, action } = typingMessage;
ev.sender = envelope.source;
ev.senderUuid = envelope.sourceUuid;
ev.senderDevice = envelope.sourceDevice;
ev.typing = {
typingMessage,
timestamp: typingMessage.timestamp
? typingMessage.timestamp.toNumber()
: Date.now(),
groupId: typingMessage.groupId
? typingMessage.groupId.toString('binary')
: null,
timestamp: timestamp ? timestamp.toNumber() : Date.now(),
groupId:
groupId && groupId.buffer.byteLength < 45
? groupId.toString('binary')
: null,
groupV2Id:
groupId && groupId.buffer.byteLength >= 45
? groupId.toString('base64')
: null,
started:
typingMessage.action ===
window.textsecure.protobuf.TypingMessage.Action.STARTED,
action === window.textsecure.protobuf.TypingMessage.Action.STARTED,
stopped:
typingMessage.action ===
window.textsecure.protobuf.TypingMessage.Action.STOPPED,
action === window.textsecure.protobuf.TypingMessage.Action.STOPPED,
};
return this.dispatchEvent(ev);
@ -1362,6 +1368,60 @@ class MessageReceiverInner extends EventTarget {
this.removeFromCache(envelope);
}
deriveGroupsV2Data(message: DataMessageClass) {
const { groupV2 } = message;
if (!groupV2) {
return;
}
if (!isNumber(groupV2.revision)) {
throw new Error('deriveGroupsV2Data: revision was not a number');
}
if (!groupV2.masterKey) {
throw new Error('deriveGroupsV2Data: had falsey masterKey');
}
const toBase64 = MessageReceiverInner.arrayBufferToStringBase64;
const masterKey: ArrayBuffer = groupV2.masterKey.toArrayBuffer();
const length = masterKey.byteLength;
if (length !== MASTER_KEY_LENGTH) {
throw new Error(
`deriveGroupsV2Data: masterKey had length ${length}, expected ${MASTER_KEY_LENGTH}`
);
}
const fields = deriveGroupFields(masterKey);
groupV2.masterKey = toBase64(masterKey);
groupV2.secretParams = toBase64(fields.secretParams);
groupV2.publicParams = toBase64(fields.publicParams);
groupV2.id = toBase64(fields.id);
if (groupV2.groupChange) {
groupV2.groupChange = groupV2.groupChange.toString('base64');
}
}
getGroupId(message: DataMessageClass) {
if (message.groupV2) {
return message.groupV2.id;
}
if (message.group) {
return message.group.id.toString('binary');
}
return null;
}
getDestination(sentMessage: SyncMessageClass.Sent) {
if (sentMessage.message && sentMessage.message.groupV2) {
return `groupv2(${sentMessage.message.groupV2.id})`;
} else if (sentMessage.message && sentMessage.message.group) {
return `group(${sentMessage.message.group.id.toBinary()})`;
} else {
return sentMessage.destination || sentMessage.destinationUuid;
}
}
// tslint:disable-next-line cyclomatic-complexity
async handleSyncMessage(
envelope: EnvelopeClass,
@ -1399,13 +1459,20 @@ class MessageReceiverInner extends EventTarget {
'MessageReceiver.handleSyncMessage: sync sent message was missing message'
);
}
const to = sentMessage.message.group
? `group(${sentMessage.message.group.id.toBinary()})`
: sentMessage.destination || sentMessage.destinationUuid;
if (!window.GV2 && sentMessage.message.groupV2) {
this.removeFromCache(envelope);
window.log.info(
'MessageReceiver.handleSyncMessage: dropping GroupV2 message'
);
return;
}
this.deriveGroupsV2Data(sentMessage.message);
window.log.info(
'sent message to',
to,
this.getDestination(sentMessage),
sentMessage.timestamp.toNumber(),
'from',
this.getEnvelopeId(envelope)
@ -1939,6 +2006,13 @@ class MessageReceiverInner extends EventTarget {
}
}
const { reaction } = decrypted;
if (reaction) {
if (reaction.targetTimestamp) {
reaction.targetTimestamp = reaction.targetTimestamp.toNumber();
}
}
return Promise.resolve(decrypted);
/* eslint-enable no-bitwise, no-param-reassign */
}
@ -1964,11 +2038,11 @@ export default class MessageReceiver {
);
this.addEventListener = inner.addEventListener.bind(inner);
this.removeEventListener = inner.removeEventListener.bind(inner);
this.getStatus = inner.getStatus.bind(inner);
this.close = inner.close.bind(inner);
this.downloadAttachment = inner.downloadAttachment.bind(inner);
this.getStatus = inner.getStatus.bind(inner);
this.hasEmptied = inner.hasEmptied.bind(inner);
this.removeEventListener = inner.removeEventListener.bind(inner);
this.stopProcessing = inner.stopProcessing.bind(inner);
this.unregisterBatchers = inner.unregisterBatchers.bind(inner);
@ -1976,12 +2050,13 @@ export default class MessageReceiver {
}
addEventListener: (name: string, handler: Function) => void;
removeEventListener: (name: string, handler: Function) => void;
getStatus: () => number;
close: () => Promise<void>;
downloadAttachment: (
attachment: AttachmentPointerClass
) => Promise<DownloadAttachmentType>;
getStatus: () => number;
hasEmptied: () => boolean;
removeEventListener: (name: string, handler: Function) => void;
stopProcessing: () => Promise<void>;
unregisterBatchers: () => void;

View file

@ -2,6 +2,7 @@
import { reject } from 'lodash';
import { ServerKeysType, WebAPIType } from './WebAPI';
import { isEnabled as isRemoteFlagEnabled } from '../RemoteConfig';
import { SignalProtocolAddressClass } from '../libsignal.d';
import { ContentClass, DataMessageClass } from '../textsecure.d';
import {
@ -574,34 +575,36 @@ export default class OutgoingMessage {
async sendToIdentifier(providedIdentifier: string) {
let identifier = providedIdentifier;
try {
if (window.isValidGuid(identifier)) {
// We're good!
} else if (isValidNumber(identifier)) {
if (!window.textsecure.messaging) {
throw new Error(
'sendToIdentifier: window.textsecure.messaging is not available!'
);
}
const lookup = await window.textsecure.messaging.getUuidsForE164s([
identifier,
]);
const uuid = lookup[identifier];
if (uuid) {
this.discoveredIdentifierPairs.push({
uuid,
e164: identifier,
});
identifier = uuid;
} else {
throw new UnregisteredUserError(
if (isRemoteFlagEnabled('desktop.cds')) {
if (window.isValidGuid(identifier)) {
// We're good!
} else if (isValidNumber(identifier)) {
if (!window.textsecure.messaging) {
throw new Error(
'sendToIdentifier: window.textsecure.messaging is not available!'
);
}
const lookup = await window.textsecure.messaging.getUuidsForE164s([
identifier,
new Error('User is not registered')
]);
const uuid = lookup[identifier];
if (uuid) {
this.discoveredIdentifierPairs.push({
uuid,
e164: identifier,
});
identifier = uuid;
} else {
throw new UnregisteredUserError(
identifier,
new Error('User is not registered')
);
}
} else {
throw new Error(
`sendToIdentifier: identifier ${identifier} was neither a UUID or E164`
);
}
} else {
throw new Error(
`sendToIdentifier: identifier ${identifier} was neither a UUID or E164`
);
}
const updateDevices = await this.getStaleDeviceIdsForIdentifier(

View file

@ -1,17 +1,31 @@
// tslint:disable no-bitwise no-default-export
import { without } from 'lodash';
import { Dictionary, without } from 'lodash';
import PQueue from 'p-queue';
import { ProxiedRequestOptionsType, WebAPIType } from './WebAPI';
import {
GroupCredentialsType,
GroupLogResponseType,
ProxiedRequestOptionsType,
WebAPIType,
} from './WebAPI';
import createTaskWithTimeout from './TaskWithTimeout';
import OutgoingMessage from './OutgoingMessage';
import Crypto from './Crypto';
import {
base64ToArrayBuffer,
concatenateBytes,
fromEncodedBinaryToArrayBuffer,
getZeroes,
hexToArrayBuffer,
} from '../Crypto';
import {
AttachmentPointerClass,
CallingMessageClass,
ContentClass,
DataMessageClass,
GroupChangeClass,
GroupClass,
StorageServiceCallOptionsType,
StorageServiceCredentials,
} from '../textsecure.d';
@ -28,12 +42,6 @@ function stringToArrayBuffer(str: string): ArrayBuffer {
}
return res;
}
function hexStringToArrayBuffer(string: string): ArrayBuffer {
return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();
}
function base64ToArrayBuffer(string: string): ArrayBuffer {
return window.dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();
}
export type SendMetadataType = {
[identifier: string]: {
@ -70,6 +78,17 @@ type QuoteAttachmentType = {
attachmentPointer?: AttachmentPointerClass;
};
type GroupV2InfoType = {
groupChange?: ArrayBuffer;
masterKey: ArrayBuffer;
revision: number;
members: Array<string>;
};
type GroupV1InfoType = {
id: string;
members: Array<string>;
};
type MessageOptionsType = {
attachments?: Array<AttachmentType> | null;
body?: string;
@ -79,6 +98,7 @@ type MessageOptionsType = {
id: string;
type: number;
};
groupV2?: GroupV2InfoType;
needsSync?: boolean;
preview?: Array<PreviewType> | null;
profileKey?: ArrayBuffer;
@ -98,6 +118,7 @@ class Message {
id: string;
type: number;
};
groupV2?: GroupV2InfoType;
needsSync?: boolean;
preview: any;
profileKey?: ArrayBuffer;
@ -117,6 +138,7 @@ class Message {
this.expireTimer = options.expireTimer;
this.flags = options.flags;
this.group = options.group;
this.groupV2 = options.groupV2;
this.needsSync = options.needsSync;
this.preview = options.preview;
this.profileKey = options.profileKey;
@ -130,7 +152,7 @@ class Message {
throw new Error('Invalid recipient list');
}
if (!this.group && this.recipients.length !== 1) {
if (!this.group && !this.groupV2 && this.recipients.length !== 1) {
throw new Error('Invalid recipient list for non-group');
}
@ -202,14 +224,19 @@ class Message {
if (this.flags) {
proto.flags = this.flags;
}
if (this.group) {
if (this.groupV2) {
proto.groupV2 = new window.textsecure.protobuf.GroupContextV2();
proto.groupV2.masterKey = this.groupV2.masterKey;
proto.groupV2.revision = this.groupV2.revision;
proto.groupV2.groupChange = this.groupV2.groupChange || null;
} else if (this.group) {
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(this.group.id);
proto.group.type = this.group.type;
}
if (this.sticker) {
proto.sticker = new window.textsecure.protobuf.DataMessage.Sticker();
proto.sticker.packId = hexStringToArrayBuffer(this.sticker.packId);
proto.sticker.packId = hexToArrayBuffer(this.sticker.packId);
proto.sticker.packKey = base64ToArrayBuffer(this.sticker.packKey);
proto.sticker.stickerId = this.sticker.stickerId;
@ -306,9 +333,9 @@ export default class MessageSender {
getPaddedAttachment(data: ArrayBuffer) {
const size = data.byteLength;
const paddedSize = this._getAttachmentSizeBucket(size);
const padding = window.Signal.Crypto.getZeroes(paddedSize - size);
const padding = getZeroes(paddedSize - size);
return window.Signal.Crypto.concatenateBytes(data, padding);
return concatenateBytes(data, padding);
}
async makeAttachmentPointer(attachment: AttachmentType) {
@ -704,7 +731,9 @@ export default class MessageSender {
return this.server.getProfile(number, options);
}
async getUuidsForE164s(numbers: Array<string>) {
async getUuidsForE164s(
numbers: Array<string>
): Promise<Dictionary<string | null>> {
return this.server.getUuidsForE164s(numbers);
}
@ -882,14 +911,14 @@ export default class MessageSender {
options: {
recipientId: string;
groupId: string;
groupNumbers: Array<string>;
groupMembers: Array<string>;
isTyping: boolean;
timestamp: number;
},
sendOptions: SendOptionsType = {}
) {
const ACTION_ENUM = window.textsecure.protobuf.TypingMessage.Action;
const { recipientId, groupId, groupNumbers, isTyping, timestamp } = options;
const { recipientId, groupId, groupMembers, isTyping, timestamp } = options;
// We don't want to send typing messages to our other devices, but we will
// in the group case.
@ -904,10 +933,10 @@ export default class MessageSender {
}
const recipients = groupId
? (without(groupNumbers, myNumber, myUuid) as Array<string>)
? (without(groupMembers, myNumber, myUuid) as Array<string>)
: [recipientId];
const groupIdBuffer = groupId
? window.Signal.Crypto.fromEncodedBinaryToArrayBuffer(groupId)
? fromEncodedBinaryToArrayBuffer(groupId)
: null;
const action = isTyping ? ACTION_ENUM.STARTED : ACTION_ENUM.STOPPED;
@ -1175,7 +1204,7 @@ export default class MessageSender {
const { packId, packKey, installed } = item;
const operation = new window.textsecure.protobuf.SyncMessage.StickerPackOperation();
operation.packId = hexStringToArrayBuffer(packId);
operation.packId = hexToArrayBuffer(packId);
operation.packKey = base64ToArrayBuffer(packKey);
operation.type = installed ? ENUM.INSTALL : ENUM.REMOVE;
@ -1466,21 +1495,48 @@ export default class MessageSender {
}
async sendMessageToGroup(
groupId: string,
recipients: Array<string>,
messageText: string,
attachments: Array<AttachmentType>,
quote: any,
preview: any,
sticker: any,
reaction: any,
timestamp: number,
expireTimer: number | undefined,
profileKey?: ArrayBuffer,
{
attachments,
expireTimer,
groupV2,
groupV1,
messageText,
preview,
profileKey,
quote,
reaction,
sticker,
timestamp,
}: {
attachments?: Array<AttachmentType>;
expireTimer?: number;
groupV2?: GroupV2InfoType;
groupV1?: GroupV1InfoType;
messageText?: string;
preview?: any;
profileKey?: ArrayBuffer;
quote?: any;
reaction?: any;
sticker?: any;
timestamp: number;
},
options?: SendOptionsType
): Promise<CallbackResultType> {
if (!groupV1 && !groupV2) {
throw new Error(
'sendMessageToGroup: Neither group1 nor groupv2 information provided!'
);
}
const myE164 = window.textsecure.storage.user.getNumber();
const myUuid = window.textsecure.storage.user.getNumber();
// prettier-ignore
const recipients = groupV2
? groupV2.members
: groupV1
? groupV1.members
: [];
const attrs = {
recipients: recipients.filter(r => r !== myE164 && r !== myUuid),
body: messageText,
@ -1492,10 +1548,13 @@ export default class MessageSender {
reaction,
expireTimer,
profileKey,
group: {
id: groupId,
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
},
groupV2,
group: groupV1
? {
id: groupV1.id,
type: window.textsecure.protobuf.GroupContext.Type.DELIVER,
}
: undefined,
};
if (recipients.length === 0) {
@ -1512,138 +1571,25 @@ export default class MessageSender {
return this.sendMessage(attrs, options);
}
async createGroup(
targetIdentifiers: Array<string>,
id: string,
name: string,
avatar: AttachmentType,
options?: SendOptionsType
) {
const proto = new window.textsecure.protobuf.DataMessage();
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(id);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.membersE164 = targetIdentifiers;
proto.group.name = name;
return this.makeAttachmentPointer(avatar).then(async attachment => {
if (!proto.group) {
throw new Error('createGroup: proto.group was set to null');
}
proto.group.avatar = attachment;
return this.sendGroupProto(
targetIdentifiers,
proto,
Date.now(),
options
).then(() => {
if (!proto.group) {
throw new Error('createGroup: proto.group was set to null');
}
return proto.group.id;
});
});
async getGroup(options: GroupCredentialsType): Promise<GroupClass> {
return this.server.getGroup(options);
}
async getGroupLog(
startVersion: number,
options: GroupCredentialsType
): Promise<GroupLogResponseType> {
return this.server.getGroupLog(startVersion, options);
}
async getGroupAvatar(key: string): Promise<ArrayBuffer> {
return this.server.getGroupAvatar(key);
}
async modifyGroup(
changes: GroupChangeClass.Actions,
options: GroupCredentialsType
): Promise<GroupChangeClass> {
return this.server.modifyGroup(changes, options);
}
async updateGroup(
groupId: string,
name: string,
avatar: AttachmentType,
targetIdentifiers: Array<string>,
options?: SendOptionsType
) {
const proto = new window.textsecure.protobuf.DataMessage();
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.name = name;
proto.group.membersE164 = targetIdentifiers;
return this.makeAttachmentPointer(avatar).then(async attachment => {
if (!proto.group) {
throw new Error('updateGroup: proto.group was set to null');
}
proto.group.avatar = attachment;
return this.sendGroupProto(
targetIdentifiers,
proto,
Date.now(),
options
).then(() => {
if (!proto.group) {
throw new Error('updateGroup: proto.group was set to null');
}
return proto.group.id;
});
});
}
async addIdentifierToGroup(
groupId: string,
newIdentifiers: Array<string>,
options: SendOptionsType
) {
const proto = new window.textsecure.protobuf.DataMessage();
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.membersE164 = newIdentifiers;
return this.sendGroupProto(newIdentifiers, proto, Date.now(), options);
}
async setGroupName(
groupId: string,
name: string,
groupIdentifiers: Array<string>,
options: SendOptionsType
) {
const proto = new window.textsecure.protobuf.DataMessage();
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.name = name;
proto.group.membersE164 = groupIdentifiers;
return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options);
}
async setGroupAvatar(
groupId: string,
avatar: AttachmentType,
groupIdentifiers: Array<string>,
options: SendOptionsType
) {
const proto = new window.textsecure.protobuf.DataMessage();
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.UPDATE;
proto.group.membersE164 = groupIdentifiers;
return this.makeAttachmentPointer(avatar).then(async attachment => {
if (!proto.group) {
throw new Error('setGroupAvatar: proto.group was set to null');
}
proto.group.avatar = attachment;
return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options);
});
}
async leaveGroup(
groupId: string,
groupIdentifiers: Array<string>,
options?: SendOptionsType
) {
const proto = new window.textsecure.protobuf.DataMessage();
proto.group = new window.textsecure.protobuf.GroupContext();
proto.group.id = stringToArrayBuffer(groupId);
proto.group.type = window.textsecure.protobuf.GroupContext.Type.QUIT;
return this.sendGroupProto(groupIdentifiers, proto, Date.now(), options);
}
async sendExpirationTimerUpdateToGroup(
groupId: string,
groupIdentifiers: Array<string>,
@ -1683,6 +1629,7 @@ export default class MessageSender {
return this.sendMessage(attrs, options);
}
async sendExpirationTimerUpdateToIdentifier(
identifier: string,
expireTimer: number | undefined,

View file

@ -35,6 +35,10 @@ import PQueue from 'p-queue';
import { v4 as getGuid } from 'uuid';
import {
AvatarUploadAttributesClass,
GroupChangeClass,
GroupChangesClass,
GroupClass,
StorageServiceCallOptionsType,
StorageServiceCredentials,
} from '../textsecure.d';
@ -283,6 +287,7 @@ type RedactUrl = (url: string) => string;
type PromiseAjaxOptionsType = {
accessKey?: string;
basicAuth?: string;
certificateAuthority?: string;
contentType?: string;
data?: ArrayBuffer | Buffer | string;
@ -309,7 +314,12 @@ type PromiseAjaxOptionsType = {
type JSONWithDetailsType = {
data: any;
contentType: string;
contentType: string | null;
response: Response;
};
type ArrayBufferWithDetailsType = {
data: ArrayBuffer;
contentType: string | null;
response: Response;
};
@ -377,8 +387,10 @@ async function _promiseAjax(
fetchOptions.headers['Content-Length'] = contentLength.toString();
}
const { accessKey, unauthenticated } = options;
if (unauthenticated) {
const { accessKey, basicAuth, unauthenticated } = options;
if (basicAuth) {
fetchOptions.headers.Authorization = `Basic ${basicAuth}`;
} else if (unauthenticated) {
if (!accessKey) {
throw new Error(
'_promiseAjax: mode is aunathenticated, but accessKey was not provided'
@ -416,6 +428,7 @@ async function _promiseAjax(
resultPromise = response.textConverted();
}
// tslint:disable-next-line max-func-body-length
return resultPromise.then(result => {
if (
options.responseType === 'arraybuffer' ||
@ -468,18 +481,29 @@ async function _promiseAjax(
} else {
window.log.info(options.type, url, response.status, 'Success');
}
if (
options.responseType === 'arraybufferwithdetails' ||
options.responseType === 'jsonwithdetails'
) {
resolve({
if (options.responseType === 'arraybufferwithdetails') {
const fullResult: ArrayBufferWithDetailsType = {
data: result,
contentType: getContentType(response),
response,
});
};
resolve(fullResult);
return;
}
if (options.responseType === 'jsonwithdetails') {
const fullResult: JSONWithDetailsType = {
data: result,
contentType: getContentType(response),
response,
};
resolve(fullResult);
return;
}
resolve(result);
return;
@ -575,29 +599,32 @@ function makeHTTPError(
const URL_CALLS = {
accounts: 'v1/accounts',
updateDeviceName: 'v1/accounts/name',
removeSignalingKey: 'v1/accounts/signaling_key',
getIceServers: 'v1/accounts/turn',
attachmentId: 'v2/attachments/form/upload',
attestation: 'v1/attestation',
config: 'v1/config',
deliveryCert: 'v1/certificate/delivery',
devices: 'v1/devices',
directoryAuth: 'v1/directory/auth',
discovery: 'v1/discovery',
getGroupAvatarUpload: '/v1/groups/avatar/form',
getGroupCredentials: 'v1/certificate/group',
getIceServers: 'v1/accounts/turn',
getStickerPackUpload: 'v1/sticker/pack/form',
groupLog: 'v1/groups/logs',
groups: 'v1/groups',
keys: 'v2/keys',
messages: 'v1/messages',
profile: 'v1/profile',
registerCapabilities: 'v1/devices/capabilities',
removeSignalingKey: 'v1/accounts/signaling_key',
signed: 'v2/keys/signed',
storageManifest: 'v1/storage/manifest',
storageModify: 'v1/storage/',
storageRead: 'v1/storage/read',
storageToken: 'v1/storage/auth',
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
getStickerPackUpload: 'v1/sticker/pack/form',
updateDeviceName: 'v1/accounts/name',
whoami: 'v1/accounts/whoami',
config: 'v1/config',
directoryAuth: 'v1/directory/auth',
// CDS endpoints
attestation: 'v1/attestation',
discovery: 'v1/discovery',
};
type InitializeOptionsType = {
@ -625,6 +652,7 @@ type MessageType = any;
type AjaxOptionsType = {
accessKey?: string;
basicAuth?: string;
call: keyof typeof URL_CALLS;
contentType?: string;
data?: ArrayBuffer | Buffer | string;
@ -648,6 +676,21 @@ export type WebAPIConnectType = {
type StickerPackManifestType = any;
export type GroupCredentialType = {
credential: string;
redemptionTime: number;
};
export type GroupCredentialsType = {
groupPublicParamsHex: string;
authCredentialPresentationHex: string;
};
export type GroupLogResponseType = {
currentRevision?: number;
start?: number;
end?: number;
changes: GroupChangesClass;
};
export type WebAPIType = {
confirmCode: (
number: string,
@ -657,9 +700,23 @@ export type WebAPIType = {
deviceName?: string | null,
options?: { accessKey?: ArrayBuffer }
) => Promise<any>;
createGroup: (
group: GroupClass,
options: GroupCredentialsType
) => Promise<void>;
getAttachment: (cdnKey: string, cdnNumber: number) => Promise<any>;
getAvatar: (path: string) => Promise<any>;
getDevices: () => Promise<any>;
getGroup: (options: GroupCredentialsType) => Promise<GroupClass>;
getGroupAvatar: (key: string) => Promise<ArrayBuffer>;
getGroupCredentials: (
startDay: number,
endDay: number
) => Promise<Array<GroupCredentialType>>;
getGroupLog: (
startVersion: number,
options: GroupCredentialsType
) => Promise<GroupLogResponseType>;
getIceServers: () => Promise<any>;
getKeysForIdentifier: (
identifier: string,
@ -701,6 +758,10 @@ export type WebAPIType = {
targetUrl: string,
options?: ProxiedRequestOptionsType
) => Promise<any>;
modifyGroup: (
changes: GroupChangeClass.Actions,
options: GroupCredentialsType
) => Promise<GroupChangeClass>;
modifyStorageRecords: MessageSender['modifyStorageRecords'];
putAttachment: (encryptedBin: ArrayBuffer) => Promise<any>;
registerCapabilities: (capabilities: any) => Promise<void>;
@ -731,6 +792,10 @@ export type WebAPIType = {
) => Promise<void>;
setSignedPreKey: (signedPreKey: SignedPreKeyType) => Promise<void>;
updateDeviceName: (deviceName: string) => Promise<void>;
uploadGroupAvatar: (
avatarData: ArrayBuffer,
options: GroupCredentialsType
) => Promise<string>;
whoami: () => Promise<any>;
getConfig: () => Promise<Array<{ name: string; enabled: boolean }>>;
};
@ -838,13 +903,20 @@ export function initialize({
let username = initialUsername;
let password = initialPassword;
const PARSE_RANGE_HEADER = /\/(\d+)$/;
const PARSE_GROUP_LOG_RANGE_HEADER = /$versions (\d{1,10})-(\d{1,10})\/(d{1,10})/;
// Thanks, function hoisting!
return {
confirmCode,
createGroup,
getAttachment,
getAvatar,
getConfig,
getDevices,
getGroup,
getGroupAvatar,
getGroupCredentials,
getGroupLog,
getIceServers,
getKeysForIdentifier,
getKeysForIdentifierUnauth,
@ -861,10 +933,11 @@ export function initialize({
getStorageRecords,
getUuidsForE164s,
makeProxiedRequest,
modifyGroup,
modifyStorageRecords,
putAttachment,
registerCapabilities,
putStickers,
registerCapabilities,
registerKeys,
registerSupportForUnauthenticatedDelivery,
removeSignalingKey,
@ -874,8 +947,8 @@ export function initialize({
sendMessagesUnauth,
setSignedPreKey,
updateDeviceName,
uploadGroupAvatar,
whoami,
getConfig,
};
async function _ajax(param: AjaxOptionsType): Promise<any> {
@ -884,6 +957,7 @@ export function initialize({
}
return _outerAjax(null, {
basicAuth: param.basicAuth,
certificateAuthority,
contentType: param.contentType || 'application/json; charset=utf-8',
data: param.data || (param.jsonData && _jsonThing(param.jsonData)),
@ -1169,11 +1243,9 @@ export function initialize({
) {
const { accessKey } = options;
const jsonData: any = {
// tslint:disable-next-line: no-suspicious-comment
// TODO: uncomment this once we want to start registering UUID support
// capabilities: {
// uuid: true,
// },
capabilities: {
gv2: true,
},
fetchesMessages: true,
name: deviceName ? deviceName : undefined,
registrationId,
@ -1695,13 +1767,13 @@ export function initialize({
return result;
}
const { response } = result;
const { response } = result as ArrayBufferWithDetailsType;
if (!response.headers || !response.headers.get) {
throw new Error('makeProxiedRequest: Problem retrieving header value');
}
const range = response.headers.get('content-range');
const match = PARSE_RANGE_HEADER.exec(range);
const match = PARSE_RANGE_HEADER.exec(range || '');
if (!match || !match[1]) {
throw new Error(
@ -1717,6 +1789,228 @@ export function initialize({
};
}
// Groups
function generateGroupAuth(
groupPublicParamsHex: string,
authCredentialPresentationHex: string
) {
return _btoa(`${groupPublicParamsHex}:${authCredentialPresentationHex}`);
}
type CredentialResponseType = {
credentials: Array<GroupCredentialType>;
};
async function getGroupCredentials(
startDay: number,
endDay: number
): Promise<Array<GroupCredentialType>> {
const response: CredentialResponseType = await _ajax({
call: 'getGroupCredentials',
urlParameters: `/${startDay}/${endDay}`,
httpType: 'GET',
responseType: 'json',
});
return response.credentials;
}
function verifyAttributes(attributes: AvatarUploadAttributesClass) {
const {
key,
credential,
acl,
algorithm,
date,
policy,
signature,
} = attributes;
if (
!key ||
!credential ||
!acl ||
!algorithm ||
!date ||
!policy ||
!signature
) {
throw new Error(
'verifyAttributes: Missing value from AvatarUploadAttributes'
);
}
return {
key,
credential,
acl,
algorithm,
date,
policy,
signature,
};
}
async function uploadGroupAvatar(
avatarData: ArrayBuffer,
options: GroupCredentialsType
): Promise<string> {
const basicAuth = generateGroupAuth(
options.groupPublicParamsHex,
options.authCredentialPresentationHex
);
const response: ArrayBuffer = await _ajax({
basicAuth,
call: 'getGroupAvatarUpload',
httpType: 'GET',
responseType: 'arraybuffer',
host: storageUrl,
});
const attributes = window.textsecure.protobuf.AvatarUploadAttributes.decode(
response
);
const verified = verifyAttributes(attributes);
const { key } = verified;
const manifestParams = makePutParams(verified, avatarData);
await _outerAjax(`${cdnUrlObject['0']}/`, {
...manifestParams,
certificateAuthority,
proxyUrl,
timeout: 0,
type: 'POST',
version,
});
return key;
}
async function getGroupAvatar(key: string): Promise<ArrayBuffer> {
return _outerAjax(`${cdnUrlObject['0']}/${key}`, {
certificateAuthority,
proxyUrl,
responseType: 'arraybuffer',
timeout: 0,
type: 'GET',
version,
});
}
async function createGroup(
group: GroupClass,
options: GroupCredentialsType
): Promise<void> {
const basicAuth = generateGroupAuth(
options.groupPublicParamsHex,
options.authCredentialPresentationHex
);
const data = group.toArrayBuffer();
await _ajax({
basicAuth,
call: 'groups',
httpType: 'PUT',
data,
host: storageUrl,
});
}
async function getGroup(
options: GroupCredentialsType
): Promise<GroupClass> {
const basicAuth = generateGroupAuth(
options.groupPublicParamsHex,
options.authCredentialPresentationHex
);
const response: ArrayBuffer = await _ajax({
basicAuth,
call: 'groups',
httpType: 'GET',
contentType: 'application/x-protobuf',
responseType: 'arraybuffer',
host: storageUrl,
});
return window.textsecure.protobuf.Group.decode(response);
}
async function modifyGroup(
changes: GroupChangeClass.Actions,
options: GroupCredentialsType
): Promise<GroupChangeClass> {
const basicAuth = generateGroupAuth(
options.groupPublicParamsHex,
options.authCredentialPresentationHex
);
const data = changes.toArrayBuffer();
const response: ArrayBuffer = await _ajax({
basicAuth,
call: 'groups',
httpType: 'PATCH',
data,
contentType: 'application/x-protobuf',
responseType: 'arraybuffer',
host: storageUrl,
});
return window.textsecure.protobuf.GroupChange.decode(response);
}
async function getGroupLog(
startVersion: number,
options: GroupCredentialsType
): Promise<GroupLogResponseType> {
const basicAuth = generateGroupAuth(
options.groupPublicParamsHex,
options.authCredentialPresentationHex
);
const withDetails: ArrayBufferWithDetailsType = await _ajax({
basicAuth,
call: 'groupLog',
urlParameters: `/${startVersion}`,
httpType: 'GET',
contentType: 'application/x-protobuf',
responseType: 'arraybufferwithdetails',
host: storageUrl,
});
const { data, response } = withDetails;
const changes = window.textsecure.protobuf.GroupChanges.decode(data);
if (response && response.status === 206) {
const range = response.headers.get('Content-Range');
const match = PARSE_GROUP_LOG_RANGE_HEADER.exec(range || '');
const start = match ? parseInt(match[0], 10) : undefined;
const end = match ? parseInt(match[1], 10) : undefined;
const currentRevision = match ? parseInt(match[2], 10) : undefined;
if (
match &&
is.number(start) &&
is.number(end) &&
is.number(currentRevision)
) {
return {
changes,
start,
end,
currentRevision,
};
}
}
return {
changes,
};
}
function getMessageSocket() {
window.log.info('opening message socket', url);
const fixedScheme = url

View file

@ -10,7 +10,7 @@ export async function deleteForEveryone(
// Make sure the server timestamps for the DOE and the matching message
// are less than one day apart
const delta = Math.abs(
doe.get('serverTimestamp') - message.get('serverTimestamp')
doe.get('serverTimestamp') - (message.get('serverTimestamp') || 0)
);
if (delta > ONE_DAY) {
window.log.info('Received late DOE. Dropping.', {

View file

@ -12852,38 +12852,6 @@
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SendMessage.js",
"line": " return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();",
"lineNumber": 25,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SendMessage.js",
"line": " return window.dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();",
"lineNumber": 28,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SendMessage.ts",
"line": " return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();",
"lineNumber": 32,
"reasonCategory": "falseMatch",
"updated": "2020-05-28T18:08:02.658Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SendMessage.ts",
"line": " return window.dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();",
"lineNumber": 35,
"reasonCategory": "falseMatch",
"updated": "2020-05-28T18:08:02.658Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SyncRequest.js",
@ -12952,7 +12920,7 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.js",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(quote, 'binary', window.dcodeIO.ByteBuffer.LITTLE_ENDIAN);",
"lineNumber": 1057,
"lineNumber": 1213,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
},
@ -12960,8 +12928,8 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/WebAPI.ts",
"line": " const byteBuffer = window.dcodeIO.ByteBuffer.wrap(",
"lineNumber": 1769,
"lineNumber": 2063,
"reasonCategory": "falseMatch",
"updated": "2020-09-08T23:07:22.682Z"
}
]
]

View file

@ -1,13 +1,21 @@
export * from 'zkgroup';
import {
AuthCredential,
ClientZkAuthOperations,
ClientZkGroupCipher,
ClientZkProfileOperations,
FFICompatArray,
FFICompatArrayType,
GroupMasterKey,
GroupSecretParams,
ProfileKey,
ProfileKeyCiphertext,
ProfileKeyCredentialPresentation,
ProfileKeyCredentialRequestContext,
ProfileKeyCredentialResponse,
ServerPublicParams,
UuidCiphertext,
} from 'zkgroup';
import {
arrayBufferToBase64,
@ -16,6 +24,8 @@ import {
typedArrayToArrayBuffer,
} from '../Crypto';
// Simple utility functions
export function arrayBufferToCompatArray(
arrayBuffer: ArrayBuffer
): FFICompatArrayType {
@ -42,6 +52,68 @@ export function compatArrayToHex(compatArray: FFICompatArrayType): string {
return arrayBufferToHex(compatArrayToArrayBuffer(compatArray));
}
// Scenarios
export function decryptGroupBlob(
clientZkGroupCipher: ClientZkGroupCipher,
ciphertext: ArrayBuffer
) {
return compatArrayToArrayBuffer(
clientZkGroupCipher.decryptBlob(arrayBufferToCompatArray(ciphertext))
);
}
export function decryptProfileKeyCredentialPresentation(
clientZkGroupCipher: ClientZkGroupCipher,
presentationBuffer: ArrayBuffer
): { profileKey: ArrayBuffer; uuid: string } {
const presentation = new ProfileKeyCredentialPresentation(
arrayBufferToCompatArray(presentationBuffer)
);
const uuidCiphertext = presentation.getUuidCiphertext();
const uuid = clientZkGroupCipher.decryptUuid(uuidCiphertext);
const profileKeyCiphertext = presentation.getProfileKeyCiphertext();
const profileKey = clientZkGroupCipher.decryptProfileKey(
profileKeyCiphertext,
uuid
);
return {
profileKey: compatArrayToArrayBuffer(profileKey.serialize()),
uuid,
};
}
export function decryptProfileKey(
clientZkGroupCipher: ClientZkGroupCipher,
profileKeyCiphertextBuffer: ArrayBuffer,
uuid: string
): ArrayBuffer {
const profileKeyCiphertext = new ProfileKeyCiphertext(
arrayBufferToCompatArray(profileKeyCiphertextBuffer)
);
const profileKey = clientZkGroupCipher.decryptProfileKey(
profileKeyCiphertext,
uuid
);
return compatArrayToArrayBuffer(profileKey.serialize());
}
export function decryptUuid(
clientZkGroupCipher: ClientZkGroupCipher,
uuidCiphertextBuffer: ArrayBuffer
): string {
const uuidCiphertext = new UuidCiphertext(
arrayBufferToCompatArray(uuidCiphertextBuffer)
);
return clientZkGroupCipher.decryptUuid(uuidCiphertext);
}
export function deriveProfileKeyVersion(
profileKeyBase64: string,
uuid: string
@ -54,13 +126,56 @@ export function deriveProfileKeyVersion(
return profileKeyVersion.toString();
}
export function getClientZkProfileOperations(
serverPublicParamsBase64: string
): ClientZkProfileOperations {
const serverPublicParamsArray = base64ToCompatArray(serverPublicParamsBase64);
const serverPublicParams = new ServerPublicParams(serverPublicParamsArray);
export function deriveGroupPublicParams(groupSecretParamsBuffer: ArrayBuffer) {
const groupSecretParams = new GroupSecretParams(
arrayBufferToCompatArray(groupSecretParamsBuffer)
);
return new ClientZkProfileOperations(serverPublicParams);
return compatArrayToArrayBuffer(
groupSecretParams.getPublicParams().serialize()
);
}
export function deriveGroupID(groupSecretParamsBuffer: ArrayBuffer) {
const groupSecretParams = new GroupSecretParams(
arrayBufferToCompatArray(groupSecretParamsBuffer)
);
return compatArrayToArrayBuffer(
groupSecretParams
.getPublicParams()
.getGroupIdentifier()
.serialize()
);
}
export function deriveGroupSecretParams(
masterKeyBuffer: ArrayBuffer
): ArrayBuffer {
const masterKey = new GroupMasterKey(
arrayBufferToCompatArray(masterKeyBuffer)
);
const groupSecretParams = GroupSecretParams.deriveFromMasterKey(masterKey);
return compatArrayToArrayBuffer(groupSecretParams.serialize());
}
export function encryptGroupBlob(
clientZkGroupCipher: ClientZkGroupCipher,
plaintext: ArrayBuffer
) {
return compatArrayToArrayBuffer(
clientZkGroupCipher.encryptBlob(arrayBufferToCompatArray(plaintext))
);
}
export function encryptUuid(
clientZkGroupCipher: ClientZkGroupCipher,
uuidPlaintext: string
): ArrayBuffer {
const uuidCiphertext = clientZkGroupCipher.encryptUuid(uuidPlaintext);
return compatArrayToArrayBuffer(uuidCiphertext.serialize());
}
export function generateProfileKeyCredentialRequest(
@ -84,13 +199,63 @@ export function generateProfileKeyCredentialRequest(
};
}
export function getAuthCredentialPresentation(
clientZkAuthOperations: ClientZkAuthOperations,
authCredentialBase64: string,
groupSecretParamsBase64: string
) {
const authCredential = new AuthCredential(
base64ToCompatArray(authCredentialBase64)
);
const secretParams = new GroupSecretParams(
base64ToCompatArray(groupSecretParamsBase64)
);
const presentation = clientZkAuthOperations.createAuthCredentialPresentation(
secretParams,
authCredential
);
return compatArrayToArrayBuffer(presentation.serialize());
}
export function getClientZkAuthOperations(
serverPublicParamsBase64: string
): ClientZkAuthOperations {
const serverPublicParams = new ServerPublicParams(
base64ToCompatArray(serverPublicParamsBase64)
);
return new ClientZkAuthOperations(serverPublicParams);
}
export function getClientZkGroupCipher(
groupSecretParamsBase64: string
): ClientZkGroupCipher {
const serverPublicParams = new GroupSecretParams(
base64ToCompatArray(groupSecretParamsBase64)
);
return new ClientZkGroupCipher(serverPublicParams);
}
export function getClientZkProfileOperations(
serverPublicParamsBase64: string
): ClientZkProfileOperations {
const serverPublicParams = new ServerPublicParams(
base64ToCompatArray(serverPublicParamsBase64)
);
return new ClientZkProfileOperations(serverPublicParams);
}
export function handleProfileKeyCredential(
clientZkProfileCipher: ClientZkProfileOperations,
context: ProfileKeyCredentialRequestContext,
responseBase64: string
): string {
const responseArray = base64ToCompatArray(responseBase64);
const response = new ProfileKeyCredentialResponse(responseArray);
const response = new ProfileKeyCredentialResponse(
base64ToCompatArray(responseBase64)
);
const profileKeyCredential = clientZkProfileCipher.receiveProfileKeyCredential(
context,
response

19
ts/window.d.ts vendored
View file

@ -23,6 +23,7 @@ import { CallHistoryDetailsType } from './types/Calling';
import { ColorType } from './types/Colors';
import { ConversationController } from './ConversationController';
import { SendOptionsType } from './textsecure/SendMessage';
import AccountManager from './textsecure/AccountManager';
import Data from './sql/Client';
export { Long } from 'long';
@ -32,6 +33,7 @@ type TaskResultType = any;
declare global {
interface Window {
dcodeIO: DCodeIOType;
getAccountManager: () => AccountManager | undefined;
getAlwaysRelayCalls: () => Promise<boolean>;
getCallRingtoneNotification: () => Promise<boolean>;
getCallSystemNotification: () => Promise<boolean>;
@ -43,8 +45,10 @@ declare global {
getIncomingCallNotification: () => Promise<boolean>;
getMediaCameraPermissions: () => Promise<boolean>;
getMediaPermissions: () => Promise<boolean>;
getServerPublicParams: () => string;
getSocketStatus: () => number;
getTitle: () => string;
waitForEmptyEventQueue: () => Promise<void>;
showCallingPermissionsPopup: (forCamera: boolean) => Promise<void>;
i18n: LocalizerType;
isValidGuid: (maybeGuid: string) => boolean;
@ -88,13 +92,24 @@ declare global {
Services: {
calling: CallingClass;
};
Migrations: {
deleteAttachmentData: (path: string) => Promise<void>;
writeNewAttachmentData: (data: ArrayBuffer) => Promise<string>;
};
Types: {
Message: {
CURRENT_SCHEMA_VERSION: number;
};
};
};
ConversationController: ConversationController;
MessageController: MessageControllerType;
WebAPI: WebAPIConnectType;
Whisper: WhisperType;
// Flags
CALLING: boolean;
GV2: boolean;
}
interface Error {
@ -114,6 +129,10 @@ export type DCodeIOType = {
};
};
type MessageControllerType = {
register: (id: string, model: MessageModelType) => MessageModelType;
};
export class CertificateValidatorType {
validate: (cerficate: any, certificateTime: number) => Promise<void>;
}