Support for new GroupV2 groups
This commit is contained in:
parent
1ce0959fa1
commit
7a02cc815d
53 changed files with 7326 additions and 839 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -13,6 +13,7 @@ release/
|
|||
.nyc_output/
|
||||
*.sublime*
|
||||
/sql/
|
||||
/start.sh
|
||||
|
||||
# generated files
|
||||
js/components.js
|
||||
|
|
|
@ -18,6 +18,9 @@ example
|
|||
coverage
|
||||
.nyc_output
|
||||
|
||||
# unneeded files
|
||||
*.js.map
|
||||
|
||||
# build scripts
|
||||
Makefile
|
||||
Gulpfile.js
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
426
js/background.js
426
js/background.js
|
@ -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(
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
if (conversation.isPrivate()) {
|
||||
recipients = [conversation.id];
|
||||
} else {
|
||||
recipients = conversation.get('members') || [];
|
||||
recipients = conversation.getMemberIds();
|
||||
}
|
||||
const receipts = this.filter(
|
||||
receipt =>
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
if (conversation.isPrivate()) {
|
||||
ids = [conversation.id];
|
||||
} else {
|
||||
ids = conversation.get('members');
|
||||
ids = conversation.getMemberIds();
|
||||
}
|
||||
const receipts = this.filter(
|
||||
receipt =>
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
className: 'contact-wrapper',
|
||||
Component: window.Signal.Components.ContactListItem,
|
||||
props: {
|
||||
...this.model.cachedProps,
|
||||
...this.model.format(),
|
||||
onClick: this.showIdentity.bind(this),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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');
|
||||
})();
|
||||
|
|
|
@ -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
152
protos/Groups.proto
Normal 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; // Server’s 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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -161,7 +161,7 @@ describe('Message', () => {
|
|||
left: 'You',
|
||||
},
|
||||
}).getNotificationData(),
|
||||
{ text: 'You left the group.' }
|
||||
{ text: 'You are no longer a member of the group.' }
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
/**
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -228,7 +228,7 @@ const stories: Array<ConversationHeaderStory> = [
|
|||
phoneNumber: '',
|
||||
id: '2',
|
||||
type: 'group',
|
||||
leftGroup: true,
|
||||
disableTimerChanges: true,
|
||||
expirationSettingName: '10 seconds',
|
||||
timerOptions: [
|
||||
{
|
||||
|
|
|
@ -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
|
||||
|
|
866
ts/components/conversation/GroupV2Change.stories.tsx
Normal file
866
ts/components/conversation/GroupV2Change.stories.tsx
Normal 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,
|
||||
},
|
||||
],
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
60
ts/components/conversation/GroupV2Change.tsx
Normal file
60
ts/components/conversation/GroupV2Change.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -256,6 +256,7 @@ const renderItem = (id: string) => (
|
|||
i18n={i18n}
|
||||
conversationId=""
|
||||
conversationAccepted
|
||||
renderContact={() => '*ContactName*'}
|
||||
{...actions()}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
536
ts/groupChange.ts
Normal 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
2296
ts/groups.ts
Normal file
File diff suppressed because it is too large
Load diff
88
ts/model-types.d.ts
vendored
88
ts/model-types.d.ts
vendored
|
@ -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(
|
||||
|
|
212
ts/services/groupCredentialFetcher.ts
Normal file
212
ts/services/groupCredentialFetcher.ts
Normal 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);
|
||||
}
|
|
@ -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! ${
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
32
ts/state/smart/ContactName.tsx
Normal file
32
ts/state/smart/ContactName.tsx
Normal 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} />;
|
||||
};
|
|
@ -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
307
ts/textsecure.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.', {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
]
|
|
@ -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
19
ts/window.d.ts
vendored
|
@ -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>;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue