Merge branch 'develop' into t3chguy/warn_self_mute

This commit is contained in:
Michael Telatynski 2018-06-29 19:17:43 +01:00 committed by GitHub
commit ea6e1b6f72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
157 changed files with 5426 additions and 3634 deletions

View file

@ -2,9 +2,7 @@
src/autocomplete/AutocompleteProvider.js
src/autocomplete/Autocompleter.js
src/autocomplete/EmojiProvider.js
src/autocomplete/UserProvider.js
src/CallHandler.js
src/component-index.js
src/components/structures/BottomLeftMenu.js
src/components/structures/CompatibilityPage.js
@ -13,27 +11,22 @@ src/components/structures/HomePage.js
src/components/structures/LeftPanel.js
src/components/structures/LoggedInView.js
src/components/structures/login/ForgotPassword.js
src/components/structures/login/Login.js
src/components/structures/login/Registration.js
src/components/structures/LoginBox.js
src/components/structures/MessagePanel.js
src/components/structures/NotificationPanel.js
src/components/structures/RoomDirectory.js
src/components/structures/RoomStatusBar.js
src/components/structures/RoomSubList.js
src/components/structures/RoomView.js
src/components/structures/ScrollPanel.js
src/components/structures/SearchBox.js
src/components/structures/TimelinePanel.js
src/components/structures/UploadBar.js
src/components/structures/UserSettings.js
src/components/structures/ViewSource.js
src/components/views/avatars/BaseAvatar.js
src/components/views/avatars/GroupAvatar.js
src/components/views/avatars/MemberAvatar.js
src/components/views/create_room/RoomAlias.js
src/components/views/dialogs/BugReportDialog.js
src/components/views/dialogs/ChangelogDialog.js
src/components/views/dialogs/ChatCreateOrReuseDialog.js
src/components/views/dialogs/DeactivateAccountDialog.js
src/components/views/dialogs/SetPasswordDialog.js
src/components/views/dialogs/UnknownDeviceDialog.js
@ -41,12 +34,12 @@ src/components/views/directory/NetworkDropdown.js
src/components/views/elements/AddressSelector.js
src/components/views/elements/DeviceVerifyButtons.js
src/components/views/elements/DirectorySearchBox.js
src/components/views/elements/EditableText.js
src/components/views/elements/ImageView.js
src/components/views/elements/InlineSpinner.js
src/components/views/elements/MemberEventListSummary.js
src/components/views/elements/Spinner.js
src/components/views/elements/TintableSvg.js
src/components/views/elements/UserInfo.js
src/components/views/elements/UserSelector.js
src/components/views/globals/MatrixToolbar.js
src/components/views/globals/NewVersionBar.js
@ -65,7 +58,6 @@ src/components/views/room_settings/UrlPreviewSettings.js
src/components/views/rooms/Autocomplete.js
src/components/views/rooms/AuxPanel.js
src/components/views/rooms/EntityTile.js
src/components/views/rooms/EventTile.js
src/components/views/rooms/LinkPreviewWidget.js
src/components/views/rooms/MemberDeviceInfo.js
src/components/views/rooms/MemberInfo.js
@ -73,12 +65,11 @@ src/components/views/rooms/MemberList.js
src/components/views/rooms/MemberTile.js
src/components/views/rooms/MessageComposer.js
src/components/views/rooms/MessageComposerInput.js
src/components/views/rooms/PinnedEventTile.js
src/components/views/rooms/RoomDropTarget.js
src/components/views/rooms/RoomList.js
src/components/views/rooms/RoomPreviewBar.js
src/components/views/rooms/RoomSettings.js
src/components/views/rooms/RoomTile.js
src/components/views/rooms/RoomTooltip.js
src/components/views/rooms/SearchableEntityList.js
src/components/views/rooms/SearchBar.js
src/components/views/rooms/SearchResultTile.js
@ -86,12 +77,12 @@ src/components/views/rooms/TopUnreadMessagesBar.js
src/components/views/rooms/UserTile.js
src/components/views/settings/AddPhoneNumber.js
src/components/views/settings/ChangeAvatar.js
src/components/views/settings/ChangeDisplayName.js
src/components/views/settings/ChangePassword.js
src/components/views/settings/DevicesPanel.js
src/components/views/settings/IntegrationsManager.js
src/components/views/settings/Notifications.js
src/ContentMessages.js
src/GroupAddressPicker.js
src/HtmlUtils.js
src/ImageUtils.js
src/languageHandler.js
@ -135,6 +126,7 @@ test/components/structures/TimelinePanel-test.js
test/components/views/dialogs/InteractiveAuthDialog-test.js
test/components/views/login/RegistrationForm-test.js
test/components/views/rooms/MessageComposerInput-test.js
test/components/views/rooms/RoomSettings-test.js
test/mock-clock.js
test/notifications/ContentRules-test.js
test/notifications/PushRuleVectorState-test.js

View file

@ -1,3 +1,117 @@
Changes in [0.12.8](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.8) (2018-06-29)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.8-rc.2...v0.12.8)
* Revert "affix copyButton so that it doesn't get scrolled horizontally"
[\#2013](https://github.com/matrix-org/matrix-react-sdk/pull/2013)
* don't fire share dialog when clicking timestamp of event
[\#2017](https://github.com/matrix-org/matrix-react-sdk/pull/2017)
* when the user switches room, close room settings
[\#2019](https://github.com/matrix-org/matrix-react-sdk/pull/2019)
Changes in [0.12.8-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.8-rc.2) (2018-06-22)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.8-rc.1...v0.12.8-rc.2)
* slash got consumed in the consolidation
[\#1998](https://github.com/matrix-org/matrix-react-sdk/pull/1998)
Changes in [0.12.8-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.8-rc.1) (2018-06-21)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.7...v0.12.8-rc.1)
* Update from Weblate.
[\#1997](https://github.com/matrix-org/matrix-react-sdk/pull/1997)
* refactor, consolidate and improve SlashCommands
[\#1988](https://github.com/matrix-org/matrix-react-sdk/pull/1988)
* Take replies out of labs!
[\#1996](https://github.com/matrix-org/matrix-react-sdk/pull/1996)
* re-merge reset PR
[\#1987](https://github.com/matrix-org/matrix-react-sdk/pull/1987)
* once command has a space, strict match instead of fuzzy match
[\#1985](https://github.com/matrix-org/matrix-react-sdk/pull/1985)
* Fix matrix.to URL RegExp
[\#1986](https://github.com/matrix-org/matrix-react-sdk/pull/1986)
* Fix blank sticker picker
[\#1984](https://github.com/matrix-org/matrix-react-sdk/pull/1984)
* fix e2ee file/media stuff
[\#1972](https://github.com/matrix-org/matrix-react-sdk/pull/1972)
* right click for room tile context menu
[\#1978](https://github.com/matrix-org/matrix-react-sdk/pull/1978)
* only show m.room.message in FilePanel
[\#1983](https://github.com/matrix-org/matrix-react-sdk/pull/1983)
* improve command provider
[\#1981](https://github.com/matrix-org/matrix-react-sdk/pull/1981)
* affix copyButton so that it doesn't get scrolled horizontally
[\#1980](https://github.com/matrix-org/matrix-react-sdk/pull/1980)
* split continuation if there is a gap in conversation
[\#1979](https://github.com/matrix-org/matrix-react-sdk/pull/1979)
* fix a bunch of instances of react console spam
[\#1973](https://github.com/matrix-org/matrix-react-sdk/pull/1973)
* Track decryption success/failure rate with piwik
[\#1949](https://github.com/matrix-org/matrix-react-sdk/pull/1949)
* route matrix.to/#/+... links internally (not just group ids)
[\#1975](https://github.com/matrix-org/matrix-react-sdk/pull/1975)
* implement `hitting enter after Ctrl-K should switch to the first result`
[\#1976](https://github.com/matrix-org/matrix-react-sdk/pull/1976)
* Remove tag panel feature flag
[\#1970](https://github.com/matrix-org/matrix-react-sdk/pull/1970)
* QuestionDialog pass hasCancelButton to DialogButtons
[\#1968](https://github.com/matrix-org/matrix-react-sdk/pull/1968)
* check type before msgtype in the case of `m.sticker` with msgtype
[\#1965](https://github.com/matrix-org/matrix-react-sdk/pull/1965)
* apply roomlist searchFilter to aliases if it begins with a `#`
[\#1957](https://github.com/matrix-org/matrix-react-sdk/pull/1957)
* Share Dialog
[\#1948](https://github.com/matrix-org/matrix-react-sdk/pull/1948)
* make RoomTooltip generic and add ContextMenu&Tooltip to GroupInviteTile
[\#1950](https://github.com/matrix-org/matrix-react-sdk/pull/1950)
* Fix widgets re-appearing after being deleted
[\#1958](https://github.com/matrix-org/matrix-react-sdk/pull/1958)
* Fix crash on unspecified thumbnail info, and handle gracefully
[\#1967](https://github.com/matrix-org/matrix-react-sdk/pull/1967)
* fix styling of clearButton when its not there
[\#1964](https://github.com/matrix-org/matrix-react-sdk/pull/1964)
* Implement slightly magical CSS soln. to thumbnail sizing
[\#1912](https://github.com/matrix-org/matrix-react-sdk/pull/1912)
* Select audio output for WebRTC
[\#1932](https://github.com/matrix-org/matrix-react-sdk/pull/1932)
* move css rule to be more generic; remove overriden rule
[\#1962](https://github.com/matrix-org/matrix-react-sdk/pull/1962)
* improve tag panel accessibility and remove a no-op dispatch
[\#1960](https://github.com/matrix-org/matrix-react-sdk/pull/1960)
* Revert "Fix exception when opening dev tools"
[\#1963](https://github.com/matrix-org/matrix-react-sdk/pull/1963)
* fix message appears unencrypted while encrypting and not_sent
[\#1959](https://github.com/matrix-org/matrix-react-sdk/pull/1959)
* Fix exception when opening dev tools
[\#1961](https://github.com/matrix-org/matrix-react-sdk/pull/1961)
* show redacted stickers like other redacted messages
[\#1956](https://github.com/matrix-org/matrix-react-sdk/pull/1956)
* add mx_filterFlipColor to mx_MemberInfo_cancel img
[\#1951](https://github.com/matrix-org/matrix-react-sdk/pull/1951)
* don't set the displayname on registration as Synapse now does it
[\#1953](https://github.com/matrix-org/matrix-react-sdk/pull/1953)
* allow CreateRoom to scale properly horizontally
[\#1955](https://github.com/matrix-org/matrix-react-sdk/pull/1955)
* Keep context menus that extend downwards vertically on screen
[\#1952](https://github.com/matrix-org/matrix-react-sdk/pull/1952)
* re-run checkIfAlone if a member change occurred in the active room
[\#1947](https://github.com/matrix-org/matrix-react-sdk/pull/1947)
* Persist pinned message open-ness between room switches
[\#1935](https://github.com/matrix-org/matrix-react-sdk/pull/1935)
* Pinned message cosmetic improvements
[\#1933](https://github.com/matrix-org/matrix-react-sdk/pull/1933)
* Update sinon to 5.0.7
[\#1916](https://github.com/matrix-org/matrix-react-sdk/pull/1916)
* re-run checkIfAlone if a member change occurred in the active room
[\#1946](https://github.com/matrix-org/matrix-react-sdk/pull/1946)
* Replace "Login as guest" with "Try the app first" on login page
[\#1937](https://github.com/matrix-org/matrix-react-sdk/pull/1937)
* kill stream when using gUM for permission to device labels to turn off
camera
[\#1931](https://github.com/matrix-org/matrix-react-sdk/pull/1931)
Changes in [0.12.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.7) (2018-06-12)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.7-rc.1...v0.12.7)

2208
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "0.12.7",
"version": "0.12.8",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -73,13 +73,14 @@
"glob": "^5.0.14",
"highlight.js": "^9.0.0",
"isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3",
"linkifyjs": "^2.1.6",
"lodash": "^4.13.1",
"lolex": "2.3.2",
"matrix-js-sdk": "0.10.4",
"matrix-js-sdk": "0.10.5",
"optimist": "^0.6.1",
"pako": "^1.0.5",
"prop-types": "^15.5.8",
"qrcode-react": "^0.1.16",
"querystring": "^0.2.0",
"react": "^15.6.0",
"react-addons-css-transition-group": "15.3.2",

View file

@ -42,6 +42,7 @@
@import "./views/dialogs/_SetEmailDialog.scss";
@import "./views/dialogs/_SetMxIdDialog.scss";
@import "./views/dialogs/_SetPasswordDialog.scss";
@import "./views/dialogs/_ShareDialog.scss";
@import "./views/dialogs/_UnknownDeviceDialog.scss";
@import "./views/directory/_NetworkDropdown.scss";
@import "./views/elements/_AccessibleButton.scss";

View file

@ -16,7 +16,7 @@ limitations under the License.
.mx_ContextualMenu_wrapper {
position: fixed;
z-index: 2000;
z-index: 5000;
}
.mx_ContextualMenu_background {
@ -26,7 +26,7 @@ limitations under the License.
width: 100%;
height: 100%;
opacity: 1.0;
z-index: 2000;
z-index: 5000;
}
.mx_ContextualMenu {
@ -37,7 +37,7 @@ limitations under the License.
position: absolute;
padding: 6px;
font-size: 14px;
z-index: 2001;
z-index: 5001;
}
.mx_ContextualMenu.mx_ContextualMenu_right {

View file

@ -113,6 +113,8 @@ limitations under the License.
}
.mx_RoomStatusBar_connectionLostBar {
display: flex;
margin-top: 19px;
min-height: 58px;
}
@ -132,6 +134,7 @@ limitations under the License.
color: $primary-fg-color;
font-size: 13px;
opacity: 0.5;
padding-bottom: 20px;
}
.mx_RoomStatusBar_resend_link {

View file

@ -91,6 +91,10 @@ limitations under the License.
background-color: $accent-color;
}
.mx_RoomSubList_label .mx_RoomSubList_badge:hover {
filter: brightness($focus-brightness);
}
/*
.collapsed .mx_RoomSubList_badge {
display: none;

View file

@ -17,7 +17,6 @@ limitations under the License.
.mx_TagPanel {
flex: 0 0 60px;
background-color: $tertiary-accent-color;
cursor: pointer;
display: flex;
flex-direction: column;
@ -25,7 +24,11 @@ limitations under the License.
justify-content: space-between;
}
.mx_TagPanel .mx_TagPanel_clearButton {
.mx_TagPanel_items_selected {
cursor: pointer;
}
.mx_TagPanel .mx_TagPanel_clearButton_container {
/* Constant height within flex mx_TagPanel */
height: 70px;
width: 60px;

View file

@ -23,6 +23,10 @@ limitations under the License.
padding-bottom: 12px;
}
.mx_CreateRoomDialog_input_container {
padding-right: 20px;
}
.mx_CreateRoomDialog_input {
font-size: 15px;
border-radius: 3px;
@ -30,4 +34,5 @@ limitations under the License.
padding: 9px;
color: $primary-fg-color;
background-color: $primary-bg-color;
width: 100%;
}

View file

@ -0,0 +1,89 @@
/*
Copyright 2018 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ShareDialog {
// this is to center the content
padding-right: 58px;
}
.mx_ShareDialog hr {
margin-top: 25px;
margin-bottom: 25px;
border-color: $light-fg-color;
}
.mx_ShareDialog_content {
margin: 10px 0;
}
.mx_ShareDialog_matrixto {
display: flex;
justify-content: space-between;
border-radius: 5px;
border: solid 1px $light-fg-color;
margin-bottom: 10px;
margin-top: 30px;
padding: 10px;
}
.mx_ShareDialog_matrixto a {
text-decoration: none;
}
.mx_ShareDialog_matrixto_link {
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.mx_ShareDialog_matrixto_copy {
flex-shrink: 0;
cursor: pointer;
margin-left: 20px;
display: inherit;
}
.mx_ShareDialog_matrixto_copy > div {
background-image: url($copy-button-url);
margin-left: 5px;
width: 20px;
height: 20px;
}
.mx_ShareDialog_split {
display: flex;
flex-wrap: wrap;
}
.mx_ShareDialog_qrcode_container {
float: left;
background-color: #ffffff;
padding: 5px; // makes qr code more readable in dark theme
border-radius: 5px;
height: 256px;
width: 256px;
margin-right: 64px;
}
.mx_ShareDialog_social_container {
display: inline-block;
width: 299px;
}
.mx_ShareDialog_social_icon {
display: inline-grid;
margin-right: 10px;
margin-bottom: 10px;
}

View file

@ -4,6 +4,7 @@
.mx_UserPill,
.mx_RoomPill,
.mx_GroupPill,
.mx_AtRoomPill {
border-radius: 16px;
display: inline-block;
@ -13,7 +14,8 @@
}
.mx_EventTile_body .mx_UserPill,
.mx_EventTile_body .mx_RoomPill {
.mx_EventTile_body .mx_RoomPill,
.mx_EventTile_body .mx_GroupPill {
cursor: pointer;
}
@ -35,14 +37,25 @@
/* More specific to override `.markdown-body a` color */
.mx_EventTile_content .markdown-body a.mx_RoomPill,
.mx_RoomPill {
.mx_EventTile_content .markdown-body a.mx_GroupPill,
.mx_RoomPill,
.mx_GroupPill {
color: $accent-fg-color;
background-color: $rte-room-pill-color;
padding-right: 5px;
}
/* More specific to override `.markdown-body a` color */
.mx_EventTile_content .markdown-body a.mx_GroupPill,
.mx_GroupPill {
color: $accent-fg-color;
background-color: $rte-group-pill-color;
padding-right: 5px;
}
.mx_UserPill .mx_BaseAvatar,
.mx_RoomPill .mx_BaseAvatar,
.mx_GroupPill .mx_BaseAvatar,
.mx_AtRoomPill .mx_BaseAvatar {
position: relative;
left: -3px;

View file

@ -20,5 +20,29 @@ limitations under the License.
}
.mx_MImageBody_thumbnail {
max-width: 100%;
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
.mx_MImageBody_thumbnail_container {
// Prevent the padding-bottom (added inline in MImageBody.js) from
// affecting elements below the container.
overflow: hidden;
// Make sure the _thumbnail is positioned relative to the _container
position: relative;
}
.mx_MImageBody_thumbnail_spinner {
position: absolute;
left: 50%;
top: 50%;
}
// Inner img and TintableSvg should be centered around 0, 0
.mx_MImageBody_thumbnail_spinner > * {
transform: translate(-50%, -50%);
}

View file

@ -14,33 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_MStickerBody {
display: block;
margin-right: 34px;
min-height: 110px;
padding: 20px 0;
.mx_MStickerBody_wrapper {
padding: 20px 0px;
}
.mx_MStickerBody_image_container {
display: inline-block;
position: relative;
}
.mx_MStickerBody_image {
max-width: 100%;
opacity: 0;
}
.mx_MStickerBody_image_visible {
opacity: 1;
}
.mx_MStickerBody_placeholder {
position: absolute;
opacity: 1;
}
.mx_MStickerBody_placeholder_invisible {
transition: 500ms;
opacity: 0;
.mx_MStickerBody_tooltip {
position: absolute;
top: 50%;
}

View file

@ -17,8 +17,3 @@ limitations under the License.
.mx_MTextBody {
white-space: pre-wrap;
}
.mx_MTextBody pre{
overflow-y: auto;
max-height: 30vh;
}

View file

@ -391,6 +391,7 @@ limitations under the License.
.mx_EventTile_content .markdown-body pre {
overflow-x: overlay;
overflow-y: visible;
max-height: 30vh;
}
.mx_EventTile_content .markdown-body code {
@ -399,6 +400,12 @@ limitations under the License.
color: #333;
}
.mx_EventTile_pre_container {
// For correct positioning of _copyButton (See TextualBody)
position: relative;
}
// Inserted adjacent to <pre> blocks, (See TextualBody)
.mx_EventTile_copyButton {
position: absolute;
display: inline-block;
@ -412,7 +419,6 @@ limitations under the License.
}
.mx_EventTile_body pre {
position: relative;
border: 1px solid transparent;
}
@ -421,7 +427,7 @@ limitations under the License.
border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter
}
.mx_EventTile_body pre:hover .mx_EventTile_copyButton
.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton
{
visibility: visible;
}

View file

@ -70,6 +70,7 @@ limitations under the License.
flex: 1;
display: flex;
flex-direction: column;
cursor: text;
}
.mx_MessageComposer_input {

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="10px" height="12px" viewBox="0 0 10 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: sketchtool 39.1 (31720) - http://www.bohemiancoding.com/sketch -->
<title>48BF5D32-306C-4B20-88EB-24B1F743CAC9</title>
<desc>Created with sketchtool.</desc>
<defs></defs>
<g id="Typing-Indicator" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.4">
<g id="typing-indicator" transform="translate(-301.000000, -172.000000)" fill="#76CFA6">
<path d="M309.666667,175.666667 C309.666667,173.633333 308.033333,172 306,172 C303.966667,172 302.333333,173.633333 302.333333,175.666667 L302.333333,176.666667 L301,176.666667 L301,184 L306,184 L311,184 L311,176.666667 L309.666667,176.666667 L309.666667,175.666667 Z M306,176.666667 L303.666667,176.666667 L303.666667,175.666667 C303.666667,174.366667 304.7,173.333333 306,173.333333 C307.3,173.333333 308.333333,174.366667 308.333333,175.666667 L308.333333,176.666667 L306,176.666667 L306,176.666667 Z" id="verified_icon"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

12
res/img/e2e-not_sent.svg Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="10px" height="12px" viewBox="0 0 10 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: sketchtool 39.1 (31720) - http://www.bohemiancoding.com/sketch -->
<title>48BF5D32-306C-4B20-88EB-24B1F743CAC9</title>
<desc>Created with sketchtool.</desc>
<defs></defs>
<g id="Typing-Indicator" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="typing-indicator" transform="translate(-301.000000, -172.000000)" fill="#f44">
<path d="M309.666667,175.666667 C309.666667,173.633333 308.033333,172 306,172 C303.966667,172 302.333333,173.633333 302.333333,175.666667 L302.333333,176.666667 L301,176.666667 L301,184 L306,184 L311,184 L311,176.666667 L309.666667,176.666667 L309.666667,175.666667 Z M306,176.666667 L303.666667,176.666667 L303.666667,175.666667 C303.666667,174.366667 304.7,173.333333 306,173.333333 C307.3,173.333333 308.333333,174.366667 308.333333,175.666667 L308.333333,176.666667 L306,176.666667 L306,176.666667 Z" id="verified_icon"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

6
res/img/icons-share.svg Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 481.6 481.6" style="enable-background:new 0 0 481.6 481.6;" xml:space="preserve" width="16px" height="16px">
<g>
<path stroke="#76CFA6" stroke-width="5" d="M381.6,309.4c-27.7,0-52.4,13.2-68.2,33.6l-132.3-73.9c3.1-8.9,4.8-18.5,4.8-28.4c0-10-1.7-19.5-4.9-28.5l132.2-73.8 c15.7,20.5,40.5,33.8,68.3,33.8c47.4,0,86.1-38.6,86.1-86.1S429,0,381.5,0s-86.1,38.6-86.1,86.1c0,10,1.7,19.6,4.9,28.5 l-132.1,73.8c-15.7-20.6-40.5-33.8-68.3-33.8c-47.4,0-86.1,38.6-86.1,86.1s38.7,86.1,86.2,86.1c27.8,0,52.6-13.3,68.4-33.9 l132.2,73.9c-3.2,9-5,18.7-5,28.7c0,47.4,38.6,86.1,86.1,86.1s86.1-38.6,86.1-86.1S429.1,309.4,381.6,309.4z M381.6,27.1 c32.6,0,59.1,26.5,59.1,59.1s-26.5,59.1-59.1,59.1s-59.1-26.5-59.1-59.1S349.1,27.1,381.6,27.1z M100,299.8 c-32.6,0-59.1-26.5-59.1-59.1s26.5-59.1,59.1-59.1s59.1,26.5,59.1,59.1S132.5,299.8,100,299.8z M381.6,454.5 c-32.6,0-59.1-26.5-59.1-59.1c0-32.6,26.5-59.1,59.1-59.1s59.1,26.5,59.1,59.1C440.7,428,414.2,454.5,381.6,454.5z" fill="#76cfa6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

15
res/img/matrix-m.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 520 520" style="enable-background:new 0 0 520 520;" xml:space="preserve">
<rect width="100%" height="100%" fill="#FFFFFF"/>
<path d="M13.7,11.9v496.2h35.7V520H0V0h49.4v11.9H13.7z"/>
<path d="M166.3,169.2v25.1h0.7c6.7-9.6,14.8-17,24.2-22.2c9.4-5.3,20.3-7.9,32.5-7.9c11.7,0,22.4,2.3,32.1,6.8
c9.7,4.5,17,12.6,22.1,24c5.5-8.1,13-15.3,22.4-21.5c9.4-6.2,20.6-9.3,33.5-9.3c9.8,0,18.9,1.2,27.3,3.6c8.4,2.4,15.5,6.2,21.5,11.5
c6,5.3,10.6,12.1,14,20.6c3.3,8.5,5,18.7,5,30.7v124.1h-50.9V249.6c0-6.2-0.2-12.1-0.7-17.6c-0.5-5.5-1.8-10.3-3.9-14.3
c-2.2-4.1-5.3-7.3-9.5-9.7c-4.2-2.4-9.9-3.6-17-3.6c-7.2,0-13,1.4-17.4,4.1c-4.4,2.8-7.9,6.3-10.4,10.8c-2.5,4.4-4.2,9.4-5,15.1
c-0.8,5.6-1.3,11.3-1.3,17v103.3h-50.9v-104c0-5.5-0.1-10.9-0.4-16.3c-0.2-5.4-1.3-10.3-3.1-14.9c-1.8-4.5-4.8-8.2-9-10.9
c-4.2-2.7-10.3-4.1-18.5-4.1c-2.4,0-5.6,0.5-9.5,1.6c-3.9,1.1-7.8,3.1-11.5,6.1c-3.7,3-6.9,7.3-9.5,12.9c-2.6,5.6-3.9,13-3.9,22.1
v107.6h-50.9V169.2H166.3z"/>
<path d="M506.3,508.1V11.9h-35.7V0H520v520h-49.4v-11.9H506.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
res/img/social/email-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
res/img/social/facebook.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
res/img/social/linkedin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
res/img/social/reddit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -97,6 +97,7 @@ $voip-accept-color: #80f480;
$rte-bg-color: #e9e9e9;
$rte-code-bg-color: rgba(0, 0, 0, 0.04);
$rte-room-pill-color: #aaa;
$rte-group-pill-color: #aaa;
// ********************

View file

@ -39,9 +39,17 @@ function getRedactedHash(hash) {
return hash.replace(hashRegex, "#/$1");
}
// Return the current origin and hash separated with a `/`. This does not include query parameters.
// Return the current origin, path and hash separated with a `/`. This does
// not include query parameters.
function getRedactedUrl() {
const { origin, pathname, hash } = window.location;
const { origin, hash } = window.location;
let { pathname } = window.location;
// Redact paths which could contain unexpected PII
if (origin.startsWith('file://')) {
pathname = "/<redacted>/";
}
return origin + pathname + getRedactedHash(hash);
}
@ -191,9 +199,9 @@ class Analytics {
this._paq.push(['trackPageView']);
}
trackEvent(category, action, name) {
trackEvent(category, action, name, value) {
if (this.disabled) return;
this._paq.push(['trackEvent', category, action, name]);
this._paq.push(['trackEvent', category, action, name, value]);
}
logout() {

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -60,6 +60,8 @@ import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk';
import dis from './dispatcher';
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import SettingsStore from "./settings/SettingsStore";
import WidgetUtils from './utils/WidgetUtils';
global.mxCalls = {
//room_id: MatrixCall
@ -123,7 +125,7 @@ function _setCallListeners(call) {
description: _t(
"There are unknown devices in this room: "+
"if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call."
"possible for someone to eavesdrop on your call.",
),
button: _t('Review Devices'),
onFinished: function(confirmed) {
@ -246,66 +248,58 @@ function _onAction(payload) {
switch (payload.action) {
case 'place_call':
if (module.exports.getAnyActiveCall()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Existing Call'),
description: _t('You are already in a call.'),
});
return; // don't allow >1 call to be placed.
}
{
if (module.exports.getAnyActiveCall()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
title: _t('Existing Call'),
description: _t('You are already in a call.'),
});
return; // don't allow >1 call to be placed.
}
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
return;
}
// if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
return;
}
var room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) {
console.error("Room %s does not exist.", payload.room_id);
return;
}
const room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) {
console.error("Room %s does not exist.", payload.room_id);
return;
}
var members = room.getJoinedMembers();
if (members.length <= 1) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'),
});
return;
} else if (members.length === 2) {
console.log("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
room_id: payload.room_id,
type: payload.type,
remote_element: payload.remote_element,
local_element: payload.local_element,
});
const members = room.getJoinedMembers();
if (members.length <= 1) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'),
});
return;
} else if (members.length === 2) {
console.log("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call);
} else { // > 2
dis.dispatch({
action: "place_conference_call",
room_id: payload.room_id,
type: payload.type,
remote_element: payload.remote_element,
local_element: payload.local_element,
});
}
}
break;
case 'place_conference_call':
console.log("Place conference call in %s", payload.room_id);
if (!ConferenceHandler) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, {
description: _t('Conference calls are not supported in this client'),
});
} else if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
} else if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) {
if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) {
// Conference calls are implemented by sending the media to central
// server which combines the audio from all the participants together
// into a single stream. This is incompatible with end-to-end encryption
@ -316,47 +310,75 @@ function _onAction(payload) {
Modal.createTrackedDialog('Call Handler', 'Conference calls unsupported e2e', ErrorDialog, {
description: _t('Conference calls are not supported in encrypted rooms'),
});
return;
}
if (SettingsStore.isFeatureEnabled('feature_jitsi')) {
_startCallApp(payload.room_id, payload.type);
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, {
title: _t('Warning!'),
description: _t('Conference calling is in development and may not be reliable.'),
onFinished: (confirm)=>{
if (confirm) {
ConferenceHandler.createNewMatrixCall(
MatrixClientPeg.get(), payload.room_id,
).done(function(call) {
placeCall(call);
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err);
Modal.createTrackedDialog('Call Handler', 'Failed to set up conference call', ErrorDialog, {
title: _t('Failed to set up conference call'),
description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''),
if (!ConferenceHandler) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, {
description: _t('Conference calls are not supported in this client'),
});
} else if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, {
title: _t('Warning!'),
description: _t('Conference calling is in development and may not be reliable.'),
onFinished: (confirm)=>{
if (confirm) {
ConferenceHandler.createNewMatrixCall(
MatrixClientPeg.get(), payload.room_id,
).done(function(call) {
placeCall(call);
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err);
Modal.createTrackedDialog(
'Call Handler',
'Failed to set up conference call',
ErrorDialog,
{
title: _t('Failed to set up conference call'),
description: (
_t('Conference call failed.') +
' ' + ((err && err.message) ? err.message : '')
),
},
);
});
});
}
},
});
}
},
});
}
}
break;
case 'incoming_call':
if (module.exports.getAnyActiveCall()) {
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
// we avoid rejecting with "busy" in case the user wants to answer it on a different device.
// in future we could signal a "local busy" as a warning to the caller.
// see https://github.com/vector-im/vector-web/issues/1964
return;
}
{
if (module.exports.getAnyActiveCall()) {
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
// we avoid rejecting with "busy" in case the user wants to answer it on a different device.
// in future we could signal a "local busy" as a warning to the caller.
// see https://github.com/vector-im/vector-web/issues/1964
return;
}
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) {
return;
}
// if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) {
return;
}
var call = payload.call;
_setCallListeners(call);
_setCallState(call, call.roomId, "ringing");
const call = payload.call;
_setCallListeners(call);
_setCallState(call, call.roomId, "ringing");
}
break;
case 'hangup':
if (!calls[payload.room_id]) {
@ -378,6 +400,71 @@ function _onAction(payload) {
break;
}
}
function _startCallApp(roomId, type) {
dis.dispatch({
action: 'appsDrawer',
show: true,
});
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
console.error("Attempted to start conference call widget in unknown room: " + roomId);
return;
}
const currentJitsiWidgets = WidgetUtils.getRoomWidgets(room).filter((ev) => {
return ev.getContent().type === 'jitsi';
});
if (currentJitsiWidgets.length > 0) {
console.warn(
"Refusing to start conference call widget in " + roomId +
" a conference call widget is already present",
);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, {
title: _t('Call in Progress'),
description: _t('A call is already in progress!'),
});
return;
}
// This inherits its poor naming from the field of the same name that goes into
// the event. It's just a random string to make the Jitsi URLs unique.
const widgetSessionId = Math.random().toString(36).substring(2);
const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId;
// NB. we can't just encodeURICompoent all of these because the $ signs need to be there
// (but currently the only thing that needs encoding is the confId)
const queryString = [
'confId='+encodeURIComponent(confId),
'isAudioConf='+(type === 'voice' ? 'true' : 'false'),
'displayName=$matrix_display_name',
'avatarUrl=$matrix_avatar_url',
'email=$matrix_user_id',
].join('&');
const widgetUrl = (
'https://scalar.vector.im/api/widgets' +
'/jitsi.html?' +
queryString
);
const widgetData = { widgetSessionId };
const widgetId = (
'jitsi_' +
MatrixClientPeg.get().credentials.userId +
'_' +
Date.now()
);
WidgetUtils.setRoomWidget(roomId, widgetId, 'jitsi', widgetUrl, 'Jitsi', widgetData).then(() => {
console.log('Jitsi widget added');
}).catch((e) => {
console.error(e);
});
}
// FIXME: Nasty way of making sure we only register
// with the dispatcher once
if (!global.mxCallHandler) {

View file

@ -22,34 +22,44 @@ export default {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
return navigator.mediaDevices.enumerateDevices().then(function(devices) {
const audioIn = [];
const videoIn = [];
const audiooutput = [];
const audioinput = [];
const videoinput = [];
if (devices.some((device) => !device.label)) return false;
devices.forEach((device) => {
switch (device.kind) {
case 'audioinput': audioIn.push(device); break;
case 'videoinput': videoIn.push(device); break;
case 'audiooutput': audiooutput.push(device); break;
case 'audioinput': audioinput.push(device); break;
case 'videoinput': videoinput.push(device); break;
}
});
// console.log("Loaded WebRTC Devices", mediaDevices);
return {
audioinput: audioIn,
videoinput: videoIn,
audiooutput,
audioinput,
videoinput,
};
}, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); });
},
loadDevices: function() {
const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput");
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
Matrix.setMatrixCallAudioOutput(audioOutDeviceId);
Matrix.setMatrixCallAudioInput(audioDeviceId);
Matrix.setMatrixCallVideoInput(videoDeviceId);
},
setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioOutput(deviceId);
},
setAudioInput: function(deviceId) {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioInput(deviceId);

View file

@ -243,6 +243,7 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
const blob = new Blob([encryptResult.data]);
return matrixClient.uploadContent(blob, {
progressHandler: progressHandler,
includeFilename: false,
}).then(function(url) {
// If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and

View file

@ -0,0 +1,172 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
class DecryptionFailure {
constructor(failedEventId) {
this.failedEventId = failedEventId;
this.ts = Date.now();
}
}
export default class DecryptionFailureTracker {
// Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list
// is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did
// are added to `failuresToTrack`.
failures = [];
// Every TRACK_INTERVAL_MS (so as to spread the number of hits done on Analytics),
// one DecryptionFailure of this FIFO is removed and tracked.
failuresToTrack = [];
// Event IDs of failures that were tracked previously
trackedEventHashMap = {
// [eventId]: true
};
// Set to an interval ID when `start` is called
checkInterval = null;
trackInterval = null;
// Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
static TRACK_INTERVAL_MS = 60000;
// Call `checkFailures` every `CHECK_INTERVAL_MS`.
static CHECK_INTERVAL_MS = 5000;
// Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before moving
// the failure to `failuresToTrack`.
static GRACE_PERIOD_MS = 60000;
constructor(fn) {
if (!fn || typeof fn !== 'function') {
throw new Error('DecryptionFailureTracker requires tracking function');
}
this.trackDecryptionFailure = fn;
}
// loadTrackedEventHashMap() {
// this.trackedEventHashMap = JSON.parse(localStorage.getItem('mx-decryption-failure-event-id-hashes')) || {};
// }
// saveTrackedEventHashMap() {
// localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap));
// }
eventDecrypted(e) {
if (e.isDecryptionFailure()) {
this.addDecryptionFailureForEvent(e);
} else {
// Could be an event in the failures, remove it
this.removeDecryptionFailuresForEvent(e);
}
}
addDecryptionFailureForEvent(e) {
this.failures.push(new DecryptionFailure(e.getId()));
}
removeDecryptionFailuresForEvent(e) {
this.failures = this.failures.filter((f) => f.failedEventId !== e.getId());
}
/**
* Start checking for and tracking failures.
*/
start() {
this.checkInterval = setInterval(
() => this.checkFailures(Date.now()),
DecryptionFailureTracker.CHECK_INTERVAL_MS,
);
this.trackInterval = setInterval(
() => this.trackFailure(),
DecryptionFailureTracker.TRACK_INTERVAL_MS,
);
}
/**
* Clear state and stop checking for and tracking failures.
*/
stop() {
clearInterval(this.checkInterval);
clearInterval(this.trackInterval);
this.failures = [];
this.failuresToTrack = [];
}
/**
* Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be
* tracked. Only mark one failure per event ID.
* @param {number} nowTs the timestamp that represents the time now.
*/
checkFailures(nowTs) {
const failuresGivenGrace = [];
const failuresNotReady = [];
while (this.failures.length > 0) {
const f = this.failures.shift();
if (nowTs > f.ts + DecryptionFailureTracker.GRACE_PERIOD_MS) {
failuresGivenGrace.push(f);
} else {
failuresNotReady.push(f);
}
}
this.failures = failuresNotReady;
// Only track one failure per event
const dedupedFailuresMap = failuresGivenGrace.reduce(
(map, failure) => {
if (!this.trackedEventHashMap[failure.failedEventId]) {
return map.set(failure.failedEventId, failure);
} else {
return map;
}
},
// Use a map to preseve key ordering
new Map(),
);
const trackedEventIds = [...dedupedFailuresMap.keys()];
this.trackedEventHashMap = trackedEventIds.reduce(
(result, eventId) => ({...result, [eventId]: true}),
this.trackedEventHashMap,
);
// Commented out for now for expediency, we need to consider unbound nature of storing
// this in localStorage
// this.saveTrackedEventHashMap();
const dedupedFailures = dedupedFailuresMap.values();
this.failuresToTrack = [...this.failuresToTrack, ...dedupedFailures];
}
/**
* If there are failures that should be tracked, call the given trackDecryptionFailure
* function with the number of failures that should be tracked.
*/
trackFailure() {
if (this.failuresToTrack.length > 0) {
// Remove all failures, and expose the number of failures for now.
//
// TODO: Track a histogram of error types to cardinailty to allow for
// aggregation by error type.
this.trackDecryptionFailure(this.failuresToTrack.splice(0).length);
}
}
}

View file

@ -216,10 +216,17 @@ const sanitizeHtmlParams = {
m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN);
if (m) {
const entity = m[1];
if (entity[0] === '@') {
attribs.href = '#/user/' + entity;
} else if (entity[0] === '#' || entity[0] === '!') {
attribs.href = '#/room/' + entity;
switch (entity[0]) {
case '@':
attribs.href = '#/user/' + entity;
break;
case '+':
attribs.href = '#/group/' + entity;
break;
case '#':
case '!':
attribs.href = '#/room/' + entity;
break;
}
delete attribs.target;
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -231,11 +232,12 @@ Example:
}
*/
const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require("./MatrixClientPeg");
const MatrixEvent = require("matrix-js-sdk").MatrixEvent;
const dis = require("./dispatcher");
const Widgets = require('./utils/widgets');
import SdkConfig from './SdkConfig';
import MatrixClientPeg from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk';
import dis from './dispatcher';
import WidgetUtils from './utils/WidgetUtils';
import RoomViewStore from './stores/RoomViewStore';
import { _t } from './languageHandler';
function sendResponse(event, res) {
@ -286,51 +288,6 @@ function inviteUser(event, roomId, userId) {
});
}
/**
* Returns a promise that resolves when a widget with the given
* ID has been added as a user widget (ie. the accountData event
* arrives) or rejects after a timeout
*
* @param {string} widgetId The ID of the widget to wait for
* @param {boolean} add True to wait for the widget to be added,
* false to wait for it to be deleted.
* @returns {Promise} that resolves when the widget is available
*/
function waitForUserWidget(widgetId, add) {
return new Promise((resolve, reject) => {
const currentAccountDataEvent = MatrixClientPeg.get().getAccountData('m.widgets');
// Tests an account data event, returning true if it's in the state
// we're waiting for it to be in
function eventInIntendedState(ev) {
if (!ev || !currentAccountDataEvent.getContent()) return false;
if (add) {
return ev.getContent()[widgetId] !== undefined;
} else {
return ev.getContent()[widgetId] === undefined;
}
}
if (eventInIntendedState(currentAccountDataEvent)) {
resolve();
return;
}
function onAccountData(ev) {
if (eventInIntendedState(currentAccountDataEvent)) {
MatrixClientPeg.get().removeListener('accountData', onAccountData);
clearTimeout(timerId);
resolve();
}
}
const timerId = setTimeout(() => {
MatrixClientPeg.get().removeListener('accountData', onAccountData);
reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear"));
}, 10000);
MatrixClientPeg.get().on('accountData', onAccountData);
});
}
function setWidget(event, roomId) {
const widgetId = event.data.widget_id;
const widgetType = event.data.type;
@ -339,12 +296,6 @@ function setWidget(event, roomId) {
const widgetData = event.data.data; // optional
const userWidget = event.data.userWidget;
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
// both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields."));
@ -371,42 +322,8 @@ function setWidget(event, roomId) {
}
}
let content = {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
};
if (userWidget) {
const client = MatrixClientPeg.get();
const userWidgets = Widgets.getUserWidgets();
// Delete existing widget with ID
try {
delete userWidgets[widgetId];
} catch (e) {
console.error(`$widgetId is non-configurable`);
}
// Add new widget / update
if (widgetUrl !== null) {
userWidgets[widgetId] = {
content: content,
sender: client.getUserId(),
state_key: widgetId,
type: 'm.widget',
id: widgetId,
};
}
// This starts listening for when the echo comes back from the server
// since the widget won't appear added until this happens. If we don't
// wait for this, the action will complete but if the user is fast enough,
// the widget still won't actually be there.
client.setAccountData('m.widgets', userWidgets).then(() => {
return waitForUserWidget(widgetId, widgetUrl !== null);
}).then(() => {
WidgetUtils.setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
sendResponse(event, {
success: true,
});
@ -419,15 +336,7 @@ function setWidget(event, roomId) {
if (!roomId) {
sendError(event, _t('Missing roomId.'), null);
}
if (widgetUrl === null) { // widget is being deleted
content = {};
}
// TODO - Room widgets need to be moved to 'm.widget' state events
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => {
// XXX: We should probably wait for the echo of the state event to come back from the server,
// as we do with user widgets.
WidgetUtils.setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
sendResponse(event, {
success: true,
});
@ -451,21 +360,13 @@ function getWidgets(event, roomId) {
sendError(event, _t('This room is not recognised.'));
return;
}
// TODO - Room widgets need to be moved to 'm.widget' state events
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets");
// Only return widgets which have required fields
if (room) {
stateEvents.forEach((ev) => {
if (ev.getContent().type && ev.getContent().url) {
widgetStateEvents.push(ev.event); // return the raw event
}
});
}
// XXX: This gets the raw event object (I think because we can't
// send the MatrixEvent over postMessage?)
widgetStateEvents = WidgetUtils.getRoomWidgets(room).map((ev) => ev.event);
}
// Add user widgets (not linked to a specific room)
const userWidgets = Widgets.getUserWidgetsArray();
const userWidgets = WidgetUtils.getUserWidgetsArray();
widgetStateEvents = widgetStateEvents.concat(userWidgets);
sendResponse(event, widgetStateEvents);
@ -637,19 +538,6 @@ function returnStateEvent(event, roomId, eventType, stateKey) {
sendResponse(event, stateEvent.getContent());
}
let currentRoomId = null;
let currentRoomAlias = null;
// Listen for when a room is viewed
dis.register(onAction);
function onAction(payload) {
if (payload.action !== "view_room") {
return;
}
currentRoomId = payload.room_id;
currentRoomAlias = payload.room_alias;
}
const onMessage = function(event) {
if (!event.origin) { // stupid chrome
event.origin = event.originalEvent.origin;
@ -700,80 +588,63 @@ const onMessage = function(event) {
return;
}
}
let promise = Promise.resolve(currentRoomId);
if (!currentRoomId) {
if (!currentRoomAlias) {
sendError(event, _t('Must be viewing a room'));
return;
}
// no room ID but there is an alias, look it up.
console.log("Looking up alias " + currentRoomAlias);
promise = MatrixClientPeg.get().getRoomIdForAlias(currentRoomAlias).then((res) => {
return res.room_id;
});
if (roomId !== RoomViewStore.getRoomId()) {
sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId}));
return;
}
promise.then((viewingRoomId) => {
if (roomId !== viewingRoomId) {
sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId}));
return;
}
// Get and set room-based widgets
if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
}
// Get and set room-based widgets
if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
}
// These APIs don't require userId
if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId);
return;
} else if (event.data.action === "set_plumbing_state") {
setPlumbingState(event, roomId, event.data.status);
return;
} else if (event.data.action === "get_membership_count") {
getMembershipCount(event, roomId);
return;
} else if (event.data.action === "get_room_enc_state") {
getRoomEncState(event, roomId);
return;
} else if (event.data.action === "can_send_event") {
canSendEvent(event, roomId);
return;
}
// These APIs don't require userId
if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId);
return;
} else if (event.data.action === "set_plumbing_state") {
setPlumbingState(event, roomId, event.data.status);
return;
} else if (event.data.action === "get_membership_count") {
getMembershipCount(event, roomId);
return;
} else if (event.data.action === "get_room_enc_state") {
getRoomEncState(event, roomId);
return;
} else if (event.data.action === "can_send_event") {
canSendEvent(event, roomId);
return;
}
if (!userId) {
sendError(event, _t('Missing user_id in request'));
return;
}
switch (event.data.action) {
case "membership_state":
getMembershipState(event, roomId, userId);
break;
case "invite":
inviteUser(event, roomId, userId);
break;
case "bot_options":
botOptions(event, roomId, userId);
break;
case "set_bot_options":
setBotOptions(event, roomId, userId);
break;
case "set_bot_power":
setBotPower(event, roomId, userId, event.data.level);
break;
default:
console.warn("Unhandled postMessage event with action '" + event.data.action +"'");
break;
}
}, (err) => {
console.error(err);
sendError(event, _t('Failed to lookup current room') + '.');
});
if (!userId) {
sendError(event, _t('Missing user_id in request'));
return;
}
switch (event.data.action) {
case "membership_state":
getMembershipState(event, roomId, userId);
break;
case "invite":
inviteUser(event, roomId, userId);
break;
case "bot_options":
botOptions(event, roomId, userId);
break;
case "set_bot_options":
setBotOptions(event, roomId, userId);
break;
case "set_bot_power":
setBotPower(event, roomId, userId, event.data.level);
break;
default:
console.warn("Unhandled postMessage event with action '" + event.data.action +"'");
break;
}
};
let listenerCount = 0;

View file

@ -14,28 +14,31 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from "./MatrixClientPeg";
import dis from "./dispatcher";
import Tinter from "./Tinter";
import React from 'react';
import MatrixClientPeg from './MatrixClientPeg';
import dis from './dispatcher';
import Tinter from './Tinter';
import sdk from './index';
import { _t } from './languageHandler';
import {_t, _td} from './languageHandler';
import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
import SettingsStore, {SettingLevel} from './settings/SettingsStore';
class Command {
constructor(name, paramArgs, runFn) {
this.name = name;
this.paramArgs = paramArgs;
constructor({name, args='', description, runFn}) {
this.command = '/' + name;
this.args = args;
this.description = description;
this.runFn = runFn;
}
getCommand() {
return "/" + this.name;
return this.command;
}
getCommandWithArgs() {
return this.getCommand() + " " + this.paramArgs;
return this.getCommand() + " " + this.args;
}
run(roomId, args) {
@ -47,16 +50,12 @@ class Command {
}
}
function reject(msg) {
return {
error: msg,
};
function reject(error) {
return {error};
}
function success(promise) {
return {
promise: promise,
};
return {promise};
}
/* Disable the "unexpected this" error for these commands - all of the run
@ -65,352 +64,408 @@ function success(promise) {
/* eslint-disable babel/no-invalid-this */
const commands = {
ddg: new Command("ddg", "<query>", function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
});
return success();
export const CommandMap = {
ddg: new Command({
name: 'ddg',
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
runFn: function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
});
return success();
},
}),
// Change your nickname
nick: new Command("nick", "<display_name>", function(roomId, args) {
if (args) {
return success(
MatrixClientPeg.get().setDisplayName(args),
);
}
return reject(this.getUsage());
}),
// Changes the colorscheme of your current room
tint: new Command("tint", "<color1> [<color2>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/);
if (matches) {
Tinter.tint(matches[1], matches[4]);
const colorScheme = {};
colorScheme.primary_color = matches[1];
if (matches[4]) {
colorScheme.secondary_color = matches[4];
} else {
colorScheme.secondary_color = colorScheme.primary_color;
}
return success(
SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
);
nick: new Command({
name: 'nick',
args: '<display_name>',
description: _td('Changes your display nickname'),
runFn: function(roomId, args) {
if (args) {
return success(MatrixClientPeg.get().setDisplayName(args));
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Change the room topic
topic: new Command("topic", "<topic>", function(roomId, args) {
if (args) {
return success(
MatrixClientPeg.get().setRoomTopic(roomId, args),
);
}
return reject(this.getUsage());
}),
// Invite a user
invite: new Command("invite", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
return success(
MatrixClientPeg.get().invite(roomId, matches[1]),
);
}
}
return reject(this.getUsage());
}),
// Join a room
join: new Command("join", "#alias:domain", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') {
return reject(this.getUsage());
}
if (!roomAlias.match(/:/)) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
dis.dispatch({
action: 'view_room',
room_alias: roomAlias,
auto_join: true,
});
return success();
}
}
return reject(this.getUsage());
}),
part: new Command("part", "[#alias:domain]", function(roomId, args) {
let targetRoomId;
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') {
return reject(this.getUsage());
}
if (!roomAlias.match(/:/)) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
// Try to find a room with this alias
const rooms = MatrixClientPeg.get().getRooms();
for (let i = 0; i < rooms.length; i++) {
const aliasEvents = rooms[i].currentState.getStateEvents(
"m.room.aliases",
);
for (let j = 0; j < aliasEvents.length; j++) {
const aliases = aliasEvents[j].getContent().aliases || [];
for (let k = 0; k < aliases.length; k++) {
if (aliases[k] === roomAlias) {
targetRoomId = rooms[i].roomId;
break;
}
}
if (targetRoomId) { break; }
tint: new Command({
name: 'tint',
args: '<color1> [<color2>]',
description: _td('Changes colour scheme of current room'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(#([\da-fA-F]{3}|[\da-fA-F]{6}))( +(#([\da-fA-F]{3}|[\da-fA-F]{6})))?$/);
if (matches) {
Tinter.tint(matches[1], matches[4]);
const colorScheme = {};
colorScheme.primary_color = matches[1];
if (matches[4]) {
colorScheme.secondary_color = matches[4];
} else {
colorScheme.secondary_color = colorScheme.primary_color;
}
if (targetRoomId) { break; }
}
if (!targetRoomId) {
return reject(_t("Unrecognised room alias:") + ' ' + roomAlias);
return success(
SettingsStore.setValue('roomColor', roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
);
}
}
}
if (!targetRoomId) targetRoomId = roomId;
return success(
MatrixClientPeg.get().leave(targetRoomId).then(
function() {
dis.dispatch({action: 'view_next_room'});
},
),
);
return reject(this.getUsage());
},
}),
// Kick a user from the room with an optional reason
kick: new Command("kick", "<userId> [<reason>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(
MatrixClientPeg.get().kick(roomId, matches[1], matches[3]),
);
topic: new Command({
name: 'topic',
args: '<topic>',
description: _td('Sets the room topic'),
runFn: function(roomId, args) {
if (args) {
return success(MatrixClientPeg.get().setRoomTopic(roomId, args));
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
invite: new Command({
name: 'invite',
args: '<user-id>',
description: _td('Invites user with given id to current room'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
return success(MatrixClientPeg.get().invite(roomId, matches[1]));
}
}
return reject(this.getUsage());
},
}),
join: new Command({
name: 'join',
args: '<room-alias>',
description: _td('Joins room with given alias'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') return reject(this.getUsage());
if (!roomAlias.includes(':')) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
dis.dispatch({
action: 'view_room',
room_alias: roomAlias,
auto_join: true,
});
return success();
}
}
return reject(this.getUsage());
},
}),
part: new Command({
name: 'part',
args: '[<room-alias>]',
description: _td('Leave room'),
runFn: function(roomId, args) {
const cli = MatrixClientPeg.get();
let targetRoomId;
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') return reject(this.getUsage());
if (!roomAlias.includes(':')) {
roomAlias += ':' + cli.getDomain();
}
// Try to find a room with this alias
const rooms = cli.getRooms();
for (let i = 0; i < rooms.length; i++) {
const aliasEvents = rooms[i].currentState.getStateEvents('m.room.aliases');
for (let j = 0; j < aliasEvents.length; j++) {
const aliases = aliasEvents[j].getContent().aliases || [];
for (let k = 0; k < aliases.length; k++) {
if (aliases[k] === roomAlias) {
targetRoomId = rooms[i].roomId;
break;
}
}
if (targetRoomId) break;
}
if (targetRoomId) break;
}
if (!targetRoomId) return reject(_t('Unrecognised room alias:') + ' ' + roomAlias);
}
}
if (!targetRoomId) targetRoomId = roomId;
return success(
cli.leave(targetRoomId).then(function() {
dis.dispatch({action: 'view_next_room'});
}),
);
},
}),
kick: new Command({
name: 'kick',
args: '<user-id> [reason]',
description: _td('Kicks user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(MatrixClientPeg.get().kick(roomId, matches[1], matches[3]));
}
}
return reject(this.getUsage());
},
}),
// Ban a user from the room with an optional reason
ban: new Command("ban", "<userId> [<reason>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(
MatrixClientPeg.get().ban(roomId, matches[1], matches[3]),
);
ban: new Command({
name: 'ban',
args: '<user-id> [reason]',
description: _td('Bans user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(MatrixClientPeg.get().ban(roomId, matches[1], matches[3]));
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Unban a user from the room
unban: new Command("unban", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
// Reset the user membership to "leave" to unban him
return success(
MatrixClientPeg.get().unban(roomId, matches[1]),
);
// Unban a user from ythe room
unban: new Command({
name: 'unban',
args: '<user-id>',
description: _td('Unbans user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
// Reset the user membership to "leave" to unban him
return success(MatrixClientPeg.get().unban(roomId, matches[1]));
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
ignore: new Command("ignore", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, {
title: _t("Ignored user"),
description: (
<div>
<p>{ _t("You are now ignoring %(userId)s", {userId: userId}) }</p>
</div>
),
hasCancelButton: false,
});
}),
);
ignore: new Command({
name: 'ignore',
args: '<user-id>',
description: _td('Ignores a user, hiding their messages from you'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, {
title: _t('Ignored user'),
description: <div>
<p>{ _t('You are now ignoring %(userId)s', {userId}) }</p>
</div>,
hasCancelButton: false,
});
}),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
unignore: new Command("unignore", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
const index = ignoredUsers.indexOf(userId);
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, {
title: _t("Unignored user"),
description: (
<div>
<p>{ _t("You are no longer ignoring %(userId)s", {userId: userId}) }</p>
</div>
),
hasCancelButton: false,
});
}),
);
unignore: new Command({
name: 'unignore',
args: '<user-id>',
description: _td('Stops ignoring a user, showing their messages going forward'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
const index = ignoredUsers.indexOf(userId);
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, {
title: _t('Unignored user'),
description: <div>
<p>{ _t('You are no longer ignoring %(userId)s', {userId}) }</p>
</div>,
hasCancelButton: false,
});
}),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Define the power level of a user
op: new Command("op", "<userId> [<power level>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
let powerLevel = 50; // default power level for op
if (matches) {
const userId = matches[1];
if (matches.length === 4 && undefined !== matches[3]) {
powerLevel = parseInt(matches[3]);
}
if (!isNaN(powerLevel)) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return reject("Bad room ID: " + roomId);
op: new Command({
name: 'op',
args: '<user-id> [<power-level>]',
description: _td('Define the power level of a user'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
let powerLevel = 50; // default power level for op
if (matches) {
const userId = matches[1];
if (matches.length === 4 && undefined !== matches[3]) {
powerLevel = parseInt(matches[3]);
}
if (!isNaN(powerLevel)) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
}
const powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", "",
);
return success(
MatrixClientPeg.get().setPowerLevel(
roomId, userId, powerLevel, powerLevelEvent,
),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Reset the power level of a user
deop: new Command("deop", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return reject("Bad room ID: " + roomId);
}
deop: new Command({
name: 'deop',
args: '<user-id>',
description: _td('Deops user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
const powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", "",
);
return success(
MatrixClientPeg.get().setPowerLevel(
roomId, args, undefined, powerLevelEvent,
),
);
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent));
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Open developer tools
devtools: new Command("devtools", "", function(roomId) {
const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog");
Modal.createDialog(DevtoolsDialog, { roomId });
return success();
devtools: new Command({
name: 'devtools',
description: _td('Opens the Developer Tools dialog'),
runFn: function(roomId) {
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
Modal.createDialog(DevtoolsDialog, {roomId});
return success();
},
}),
// Verify a user, device, and pubkey tuple
verify: new Command("verify", "<userId> <deviceId> <deviceSigningKey>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
if (matches) {
const userId = matches[1];
const deviceId = matches[2];
const fingerprint = matches[3];
verify: new Command({
name: 'verify',
args: '<user-id> <device-id> <device-signing-key>',
description: _td('Verifies a user, device, and pubkey tuple'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
if (matches) {
const cli = MatrixClientPeg.get();
return success(
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => {
if (!device) {
throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`);
}
const userId = matches[1];
const deviceId = matches[2];
const fingerprint = matches[3];
if (device.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw new Error(_t(`Device already verified!`));
} else {
throw new Error(_t(`WARNING: Device already verified, but keys do NOT MATCH!`));
return success(
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => {
if (!device) {
throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`);
}
}
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new Error(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key' +
' "%(fingerprint)s". This could mean your communications are being intercepted!',
{deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint}));
}
if (device.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw new Error(_t('Device already verified!'));
} else {
throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!'));
}
}
return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, {
title: _t("Verified key"),
description: (
<div>
<p>
{
_t("The signing key you provided matches the signing key you received " +
"from %(userId)s's device %(deviceId)s. Device marked as verified.",
{userId: userId, deviceId: deviceId})
}
</p>
</div>
),
hasCancelButton: false,
});
}),
);
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new Error(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
'"%(fingerprint)s". This could mean your communications are being intercepted!',
{
fprint,
userId,
deviceId,
fingerprint,
}));
}
return cli.setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, {
title: _t('Verified key'),
description: <div>
<p>
{
_t('The signing key you provided matches the signing key you received ' +
'from %(userId)s\'s device %(deviceId)s. Device marked as verified.',
{userId, deviceId})
}
</p>
</div>,
hasCancelButton: false,
});
}),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
me: new Command({
name: 'me',
args: '<message>',
description: _td('Displays action'),
}),
};
/* eslint-enable babel/no-invalid-this */
@ -421,50 +476,39 @@ const aliases = {
j: "join",
};
module.exports = {
/**
* Process the given text for /commands and perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
* @return {Object|null} An object with the property 'error' if there was an error
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
processInput: function(roomId, input) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, "");
if (input[0] === "/" && input[1] !== "/") {
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
let cmd;
let args;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[3];
} else {
cmd = input;
}
if (cmd === "me") return null;
if (aliases[cmd]) {
cmd = aliases[cmd];
}
if (commands[cmd]) {
return commands[cmd].run(roomId, args);
} else {
return reject(_t("Unrecognised command:") + ' ' + input);
}
}
return null; // not a command
},
/**
* Process the given text for /commands and perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
* @return {Object|null} An object with the property 'error' if there was an error
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
export function processCommandInput(roomId, input) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/' || input[1] === '/') return null; // not a command
getCommandList: function() {
// Return all the commands plus /me and /markdown which aren't handled like normal commands
const cmds = Object.keys(commands).sort().map(function(cmdKey) {
return commands[cmdKey];
});
cmds.push(new Command("me", "<action>", function() {}));
cmds.push(new Command("markdown", "<on|off>", function() {}));
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
let cmd;
let args;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
args = bits[3];
} else {
cmd = input;
}
return cmds;
},
};
if (aliases[cmd]) {
cmd = aliases[cmd];
}
if (CommandMap[cmd]) {
// if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
if (!CommandMap[cmd].runFn) return null;
return CommandMap[cmd].run(roomId, args);
} else {
return reject(_t('Unrecognised command:') + ' ' + input);
}
}

View file

@ -1,93 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import SdkConfig from "./SdkConfig";
import * as url from "url";
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
* (Does not apply to non-room-based / user widgets)
* @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
static canUserModifyWidgets(roomId) {
if (!roomId) {
console.warn('No room ID specified');
return false;
}
const client = MatrixClientPeg.get();
if (!client) {
console.warn('User must be be logged in');
return false;
}
const room = client.getRoom(roomId);
if (!room) {
console.warn(`Room ID ${roomId} is not recognised`);
return false;
}
const me = client.credentials.userId;
if (!me) {
console.warn('Failed to get user ID');
return false;
}
const member = room.getMember(me);
if (!member || member.membership !== "join") {
console.warn(`User ${me} is not in room ${roomId}`);
return false;
}
return room.currentState.maySendStateEvent('im.vector.modular.widgets', me);
}
/**
* Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api
* @param {[type]} testUrlString URL to check
* @return {Boolean} True if specified URL is a scalar URL
*/
static isScalarUrl(testUrlString) {
if (!testUrlString) {
console.error('Scalar URL check failed. No URL specified');
return false;
}
const testUrl = url.parse(testUrlString);
let scalarUrls = SdkConfig.get().integrations_widgets_urls;
if (!scalarUrls || scalarUrls.length === 0) {
scalarUrls = [SdkConfig.get().integrations_rest_url];
}
for (let i = 0; i < scalarUrls.length; i++) {
const scalarUrl = url.parse(scalarUrls[i]);
if (testUrl && scalarUrl) {
if (
testUrl.protocol === scalarUrl.protocol &&
testUrl.host === scalarUrl.host &&
testUrl.pathname.startsWith(scalarUrl.pathname)
) {
return true;
}
}
}
return false;
}
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -36,7 +36,7 @@ export default class AutocompleteProvider {
/**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/
getCurrentCommand(query: string, selection: {start: number, end: number}, force: boolean = false): ?string {
getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false): ?string {
let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) {
@ -51,14 +51,14 @@ export default class AutocompleteProvider {
let match;
while ((match = commandRegex.exec(query)) != null) {
let matchStart = match.index,
matchEnd = matchStart + match[0].length;
if (selection.start <= matchEnd && selection.end >= matchStart) {
const start = match.index;
const end = start + match[0].length;
if (selection.start <= end && selection.end >= start) {
return {
command: match,
range: {
start: matchStart,
end: matchEnd,
start,
end,
},
};
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,7 +18,9 @@ limitations under the License.
// @flow
import type {Component} from 'react';
import {Room} from 'matrix-js-sdk';
import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
@ -47,6 +49,7 @@ const PROVIDERS = [
EmojiProvider,
NotifProvider,
CommandProvider,
CommunityProvider,
DuckDuckGoProvider,
];
@ -54,7 +57,7 @@ const PROVIDERS = [
const PROVIDER_COMPLETION_TIMEOUT = 3000;
export default class Autocompleter {
constructor(room) {
constructor(room: Room) {
this.room = room;
this.providers = PROVIDERS.map((p) => {
return new p(room);

View file

@ -2,6 +2,7 @@
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,103 +18,16 @@ limitations under the License.
*/
import React from 'react';
import { _t, _td } from '../languageHandler';
import {_t} from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components';
import type {Completion, SelectionRange} from "./Autocompleter";
import {CommandMap} from '../SlashCommands';
// TODO merge this with the factory mechanics of SlashCommands?
// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file
const COMMANDS = [
{
command: '/me',
args: '<message>',
description: _td('Displays action'),
},
{
command: '/ban',
args: '<user-id> [reason]',
description: _td('Bans user with given id'),
},
{
command: '/unban',
args: '<user-id>',
description: _td('Unbans user with given id'),
},
{
command: '/op',
args: '<user-id> [<power-level>]',
description: _td('Define the power level of a user'),
},
{
command: '/deop',
args: '<user-id>',
description: _td('Deops user with given id'),
},
{
command: '/invite',
args: '<user-id>',
description: _td('Invites user with given id to current room'),
},
{
command: '/join',
args: '<room-alias>',
description: _td('Joins room with given alias'),
},
{
command: '/part',
args: '[<room-alias>]',
description: _td('Leave room'),
},
{
command: '/topic',
args: '<topic>',
description: _td('Sets the room topic'),
},
{
command: '/kick',
args: '<user-id> [reason]',
description: _td('Kicks user with given id'),
},
{
command: '/nick',
args: '<display-name>',
description: _td('Changes your display nickname'),
},
{
command: '/ddg',
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
},
{
command: '/tint',
args: '<color1> [<color2>]',
description: _td('Changes colour scheme of current room'),
},
{
command: '/verify',
args: '<user-id> <device-id> <device-signing-key>',
description: _td('Verifies a user, device, and pubkey tuple'),
},
{
command: '/ignore',
args: '<user-id>',
description: _td('Ignores a user, hiding their messages from you'),
},
{
command: '/unignore',
args: '<user-id>',
description: _td('Stops ignoring a user, showing their messages going forward'),
},
{
command: '/devtools',
args: '',
description: _td('Opens the Developer Tools dialog'),
},
// Omitting `/markdown` as it only seems to apply to OldComposer
];
const COMMANDS = Object.values(CommandMap);
const COMMAND_RE = /(^\/\w*)/g;
const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider {
constructor() {
@ -123,23 +37,36 @@ export default class CommandProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> {
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.matcher.match(command[0]).map((result) => {
return {
completion: result.command + ' ',
component: (<TextualCompletion
title={result.command}
subtitle={result.args}
description={_t(result.description)}
/>),
range,
};
});
if (!command) return [];
let matches = [];
if (command[0] !== command[1]) {
// The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/`
if (CommandMap[name]) {
matches = [CommandMap[name]];
}
} else {
if (query === '/') {
// If they have just entered `/` show everything
matches = COMMANDS;
} else {
// otherwise fuzzy match against all of the fields
matches = this.matcher.match(command[1]);
}
}
return completions;
return matches.map((result) => ({
// If the command is the same as the one they entered, we don't want to discard their arguments
completion: result.command === command[1] ? command[0] : (result.command + ' '),
component: <TextualCompletion
title={result.command}
subtitle={result.args}
description={_t(result.description)} />,
range,
}));
}
getName() {

View file

@ -0,0 +1,111 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import MatrixClientPeg from '../MatrixClientPeg';
import FuzzyMatcher from './FuzzyMatcher';
import {PillCompletion} from './Components';
import sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeGroupPermalink} from "../matrix-to";
import type {Completion, SelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore";
const COMMUNITY_REGEX = /\B\+\S*/g;
function score(query, space) {
const index = space.indexOf(query);
if (index === -1) {
return Infinity;
} else {
return index;
}
}
export default class CommunityProvider extends AutocompleteProvider {
constructor() {
super(COMMUNITY_REGEX);
this.matcher = new FuzzyMatcher([], {
keys: ['groupId', 'name', 'shortDescription'],
});
}
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const cli = MatrixClientPeg.get();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
const joinedGroups = cli.getGroups().filter(({myMembership}) => myMembership === 'join');
const groups = (await Promise.all(joinedGroups.map(async ({groupId}) => {
try {
return FlairStore.getGroupProfileCached(cli, groupId);
} catch (e) { // if FlairStore failed, fall back to just groupId
return Promise.resolve({
name: '',
groupId,
avatarUrl: '',
shortDescription: '',
});
}
})));
this.matcher.setObjects(groups);
const matchedString = command[0];
completions = this.matcher.match(matchedString);
completions = _sortBy(completions, [
(c) => score(matchedString, c.groupId),
(c) => c.groupId.length,
]).map(({avatarUrl, groupId, name}) => ({
completion: groupId,
suffix: ' ',
href: makeGroupPermalink(groupId),
component: (
<PillCompletion initialComponent={
<BaseAvatar name={name || groupId}
width={24} height={24}
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 24, 24) : null} />
} title={name} description={groupId} />
),
range,
}))
.slice(0, 4);
}
return completions;
}
getName() {
return '💬 ' + _t('Communities');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions }
</div>;
}
}

View file

@ -1,7 +1,7 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -22,6 +22,7 @@ import AutocompleteProvider from './AutocompleteProvider';
import 'whatwg-fetch';
import {TextualCompletion} from './Components';
import type {SelectionRange} from "./Autocompleter";
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
@ -36,7 +37,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
async getCompletions(query: string, selection: {start: number, end: number}) {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false) {
const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return [];

View file

@ -1,7 +1,7 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -19,11 +19,11 @@ limitations under the License.
import React from 'react';
import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider';
import {emojioneList, shortnameToImage, shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
import {shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
import FuzzyMatcher from './FuzzyMatcher';
import sdk from '../index';
import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter';
import type {Completion, SelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore";
@ -95,7 +95,7 @@ export default class EmojiProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: SelectionRange) {
async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> {
if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them
}

View file

@ -20,6 +20,7 @@ import { _t } from '../languageHandler';
import MatrixClientPeg from '../MatrixClientPeg';
import {PillCompletion} from './Components';
import sdk from '../index';
import type {Completion, SelectionRange} from "./Autocompleter";
const AT_ROOM_REGEX = /@\S*/g;
@ -29,7 +30,7 @@ export default class NotifProvider extends AutocompleteProvider {
this.room = room;
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
async getCompletions(query: string, selection: SelectionRange, force?:boolean = false): Array<Completion> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();

View file

@ -1,6 +1,7 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -27,6 +28,10 @@ class KeyMap {
priorityMap = new Map();
}
function stripDiacritics(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
export default class QueryMatcher {
/**
* @param {object[]} objects the objects to perform a match on
@ -46,10 +51,11 @@ export default class QueryMatcher {
objects.forEach((object, i) => {
const keyValues = _at(object, keys);
for (const keyValue of keyValues) {
if (!map.hasOwnProperty(keyValue)) {
map[keyValue] = [];
const key = stripDiacritics(keyValue).toLowerCase();
if (!map.hasOwnProperty(key)) {
map[key] = [];
}
map[keyValue].push(object);
map[key].push(object);
}
keyMap.priorityMap.set(object, i);
});
@ -82,7 +88,7 @@ export default class QueryMatcher {
}
match(query: String): Array<Object> {
query = query.toLowerCase();
query = stripDiacritics(query).toLowerCase();
if (this.options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
}
@ -91,7 +97,7 @@ export default class QueryMatcher {
}
const results = [];
this.keyMap.keys.forEach((key) => {
let resultKey = key.toLowerCase();
let resultKey = key;
if (this.options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}

View file

@ -1,7 +1,8 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,8 +27,9 @@ import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../matrix-to";
import type {Completion, SelectionRange} from "./Autocompleter";
const ROOM_REGEX = /(?=#)(\S*)/g;
const ROOM_REGEX = /\B#\S*/g;
function score(query, space) {
const index = space.indexOf(query);
@ -46,7 +48,7 @@ export default class RoomProvider extends AutocompleteProvider {
});
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
// Disable autocompletions when composing commands because of various issues

View file

@ -2,7 +2,8 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -23,20 +24,20 @@ import AutocompleteProvider from './AutocompleteProvider';
import {PillCompletion} from './Components';
import sdk from '../index';
import FuzzyMatcher from './FuzzyMatcher';
import _pull from 'lodash/pull';
import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk';
import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk';
import {makeUserPermalink} from "../matrix-to";
import type {Completion, SelectionRange} from "./Autocompleter";
const USER_REGEX = /@\S*/g;
const USER_REGEX = /\B@\S*/g;
export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null;
room: Room = null;
constructor(room) {
constructor(room: Room) {
super(USER_REGEX, {
keys: ['name'],
});
@ -44,7 +45,7 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'],
shouldMatchPrefix: true,
shouldMatchWordsOnly: false
shouldMatchWordsOnly: false,
});
this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
@ -61,7 +62,7 @@ export default class UserProvider extends AutocompleteProvider {
}
}
_onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
_onRoomTimeline(ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean, data: Object) {
if (!room) return;
if (removed) return;
if (room.roomId !== this.room.roomId) return;
@ -77,7 +78,7 @@ export default class UserProvider extends AutocompleteProvider {
this.onUserSpoke(ev.sender);
}
_onRoomStateMember(ev, state, member) {
_onRoomStateMember(ev: MatrixEvent, state: RoomState, member: RoomMember) {
// ignore members in other rooms
if (member.roomId !== this.room.roomId) {
return;
@ -87,7 +88,7 @@ export default class UserProvider extends AutocompleteProvider {
this.users = null;
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
@ -128,7 +129,7 @@ export default class UserProvider extends AutocompleteProvider {
return completions;
}
getName() {
getName(): string {
return '👥 ' + _t('Users');
}
@ -141,13 +142,9 @@ export default class UserProvider extends AutocompleteProvider {
}
const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = this.room.getJoinedMembers().filter((member) => {
if (member.userId !== currentUserId) return true;
});
this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
this.users = _sortBy(this.users, (member) =>
1E20 - lastSpoken[member.userId] || 1E20,
);
this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
this.matcher.setObjects(this.users);
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,12 +16,10 @@ limitations under the License.
*/
'use strict';
const classNames = require('classnames');
const React = require('react');
const ReactDOM = require('react-dom');
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@ -61,6 +60,54 @@ export default class ContextualMenu extends React.Component {
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
hasBackground: PropTypes.bool,
// The component to render as the context menu
elementClass: PropTypes.element.isRequired,
// on resize callback
windowResize: PropTypes.func,
// method to close menu
closeMenu: PropTypes.func,
};
constructor() {
super();
this.state = {
contextMenuRect: null,
};
this.onContextMenu = this.onContextMenu.bind(this);
this.collectContextMenuRect = this.collectContextMenuRect.bind(this);
}
collectContextMenuRect(element) {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
this.setState({
contextMenuRect: element.getBoundingClientRect(),
});
}
onContextMenu(e) {
if (this.props.closeMenu) {
this.props.closeMenu();
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
// XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst
// a context menu and its click-guard are up without completely rewriting how the context menus work.
setImmediate(() => {
const clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent(
'contextmenu', true, true, window, 0,
0, 0, x, y, false, false,
false, false, 0, null,
);
document.elementFromPoint(x, y).dispatchEvent(clickEvent);
});
}
}
render() {
@ -83,6 +130,9 @@ export default class ContextualMenu extends React.Component {
chevronFace = 'right';
}
const contextMenuRect = this.state.contextMenuRect || null;
const padding = 10;
const chevronOffset = {};
if (props.chevronFace) {
chevronFace = props.chevronFace;
@ -90,7 +140,19 @@ export default class ContextualMenu extends React.Component {
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else {
chevronOffset.top = props.chevronOffset;
const target = position.top;
// By default, no adjustment is made
let adjusted = target;
// If we know the dimensions of the context menu, adjust its position
// such that it does not leave the (padded) window.
if (contextMenuRect) {
adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
}
position.top = adjusted;
chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted);
}
// To override the default chevron colour, if it's been set
@ -112,7 +174,7 @@ export default class ContextualMenu extends React.Component {
`;
}
const chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace}></div>;
const chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} />;
const className = 'mx_ContextualMenu_wrapper';
const menuClasses = classNames({
@ -154,17 +216,17 @@ export default class ContextualMenu extends React.Component {
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click!
return <div className={className} style={position}>
<div className={menuClasses} style={menuStyle}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}>
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu}></div> }
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style>
</div>;
}
}
export function createMenu(ElementClass, props) {
export function createMenu(ElementClass, props, hasBackground=true) {
const closeMenu = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
@ -175,8 +237,8 @@ export function createMenu(ElementClass, props) {
// We only reference closeMenu once per call to createMenu
const menu = <ContextualMenu
hasBackground={hasBackground}
{...props}
hasBackground={true}
elementClass={ElementClass}
closeMenu={closeMenu} // eslint-disable-line react/jsx-no-bind
windowResize={closeMenu} // eslint-disable-line react/jsx-no-bind

View file

@ -68,8 +68,8 @@ const FilePanel = React.createClass({
"room": {
"timeline": {
"contains_url": true,
"not_types": [
"m.sticker",
"types": [
"m.room.message",
],
},
},

View file

@ -562,6 +562,13 @@ export default React.createClass({
});
},
_onShareClick: function() {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share community dialog', '', ShareDialog, {
target: this._matrixClient.getGroup(this.props.groupId),
});
},
_onCancelClick: function() {
this._closeSettings();
},
@ -1052,7 +1059,7 @@ export default React.createClass({
<input type="radio"
value={GROUP_JOINPOLICY_INVITE}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE}
onClick={this._onJoinableChange}
onChange={this._onJoinableChange}
/>
<div className="mx_GroupView_label_text">
{ _t('Only people who have been invited') }
@ -1064,7 +1071,7 @@ export default React.createClass({
<input type="radio"
value={GROUP_JOINPOLICY_OPEN}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN}
onClick={this._onJoinableChange}
onChange={this._onJoinableChange}
/>
<div className="mx_GroupView_label_text">
{ _t('Everyone') }
@ -1127,10 +1134,6 @@ export default React.createClass({
let avatarNode;
let nameNode;
let shortDescNode;
const bodyNodes = [
this._getMembershipSection(),
this._getGroupSection(),
];
const rightButtons = [];
if (this.state.editing && this.state.isUserPrivileged) {
let avatarImage;
@ -1207,6 +1210,7 @@ export default React.createClass({
shortDescNode = <span onClick={onGroupHeaderItemClick}>{ summary.profile.short_description }</span>;
}
}
if (this.state.editing) {
rightButtons.push(
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
@ -1231,6 +1235,11 @@ export default React.createClass({
</AccessibleButton>,
);
}
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button" onClick={this._onShareClick} title={_t('Share Community')} key="_shareButton">
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>,
);
if (this.props.collapsedRhs) {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button"
@ -1269,7 +1278,8 @@ export default React.createClass({
</div>
</div>
<GeminiScrollbarWrapper className="mx_GroupView_body">
{ bodyNodes }
{ this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper>
</div>
);

View file

@ -82,17 +82,26 @@ var LeftPanel = React.createClass({
_onKeyDown: function(ev) {
if (!this.focusedElement) return;
let handled = false;
let handled = true;
switch (ev.keyCode) {
case KeyCode.TAB:
this._onMoveFocus(ev.shiftKey);
break;
case KeyCode.UP:
this._onMoveFocus(true);
handled = true;
break;
case KeyCode.DOWN:
this._onMoveFocus(false);
handled = true;
break;
case KeyCode.ENTER:
this._onMoveFocus(false);
if (this.focusedElement) {
this.focusedElement.click();
}
break;
default:
handled = false;
}
if (handled) {
@ -102,37 +111,33 @@ var LeftPanel = React.createClass({
},
_onMoveFocus: function(up) {
var element = this.focusedElement;
let element = this.focusedElement;
// unclear why this isn't needed
// var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending;
// this.focusDirection = up;
var descending = false; // are we currently descending or ascending through the DOM tree?
var classes;
let descending = false; // are we currently descending or ascending through the DOM tree?
let classes;
do {
var child = up ? element.lastElementChild : element.firstElementChild;
var sibling = up ? element.previousElementSibling : element.nextElementSibling;
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
}
else if (sibling) {
} else if (sibling) {
element = sibling;
}
else {
} else {
descending = false;
element = element.parentElement;
}
}
else {
} else {
if (sibling) {
element = sibling;
descending = true;
}
else {
} else {
element = element.parentElement;
}
}
@ -144,8 +149,7 @@ var LeftPanel = React.createClass({
descending = true;
}
}
} while(element && !(
} while (element && !(
classes.contains("mx_RoomTile") ||
classes.contains("mx_SearchBox_search") ||
classes.contains("mx_RoomSubList_ellipsis")));

View file

@ -23,6 +23,7 @@ import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics";
import DecryptionFailureTracker from "../../DecryptionFailureTracker";
import MatrixClientPeg from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
@ -1143,11 +1144,6 @@ export default React.createClass({
} else if (this._is_registered) {
this._is_registered = false;
// Set the display name = user ID localpart
MatrixClientPeg.get().setDisplayName(
MatrixClientPeg.get().getUserIdLocalpart(),
);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
createRoom({
dmUserId: this.props.config.welcomeUserId,
@ -1308,6 +1304,21 @@ export default React.createClass({
}
});
const dft = new DecryptionFailureTracker((total) => {
// TODO: Pass reason for failure as third argument to trackEvent
Analytics.trackEvent('E2E', 'Decryption failure', null, total);
});
// Shelved for later date when we have time to think about persisting history of
// tracked events across sessions.
// dft.loadTrackedEventHashMap();
dft.start();
// When logging out, stop tracking failures and destroy state
cli.on("Session.logged_out", () => dft.stop());
cli.on("Event.decrypted", (e) => dft.eventDecrypted(e));
const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => {
krh.handleKeyRequest(req);

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -25,6 +26,9 @@ import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg';
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = ['m.sticker', 'm.room.message'];
/* (almost) stateless UI component which builds the event tiles in the room timeline.
*/
module.exports = React.createClass({
@ -189,7 +193,7 @@ module.exports = React.createClass({
/**
* Page up/down.
*
* mult: -1 to page up, +1 to page down
* @param {number} mult: -1 to page up, +1 to page down
*/
scrollRelative: function(mult) {
if (this.refs.scrollPanel) {
@ -199,6 +203,8 @@ module.exports = React.createClass({
/**
* Scroll up/down in response to a scroll key
*
* @param {KeyboardEvent} ev: the keyboard event to handle
*/
handleScrollKey: function(ev) {
if (this.refs.scrollPanel) {
@ -257,6 +263,7 @@ module.exports = React.createClass({
this.eventNodes = {};
let visible = false;
let i;
// first figure out which is the last event in the list which we're
@ -297,7 +304,7 @@ module.exports = React.createClass({
// if the readmarker has moved, cancel any active ghost.
if (this.currentReadMarkerEventId && this.props.readMarkerEventId &&
this.props.readMarkerVisible &&
this.currentReadMarkerEventId != this.props.readMarkerEventId) {
this.currentReadMarkerEventId !== this.props.readMarkerEventId) {
this.currentGhostEventId = null;
}
@ -404,8 +411,8 @@ module.exports = React.createClass({
let isVisibleReadMarker = false;
if (eventId == this.props.readMarkerEventId) {
var visible = this.props.readMarkerVisible;
if (eventId === this.props.readMarkerEventId) {
visible = this.props.readMarkerVisible;
// if the read marker comes at the end of the timeline (except
// for local echoes, which are excluded from RMs, because they
@ -423,11 +430,11 @@ module.exports = React.createClass({
// XXX: there should be no need for a ghost tile - we should just use a
// a dispatch (user_activity_end) to start the RM animation.
if (eventId == this.currentGhostEventId) {
if (eventId === this.currentGhostEventId) {
// if we're showing an animation, continue to show it.
ret.push(this._getReadMarkerGhostTile());
} else if (!isVisibleReadMarker &&
eventId == this.currentReadMarkerEventId) {
eventId === this.currentReadMarkerEventId) {
// there is currently a read-up-to marker at this point, but no
// more. Show an animation of it disappearing.
ret.push(this._getReadMarkerGhostTile());
@ -449,16 +456,17 @@ module.exports = React.createClass({
// Some events should appear as continuations from previous events of
// different types.
const continuedTypes = ['m.sticker', 'm.room.message'];
const eventTypeContinues =
prevEvent !== null &&
continuedTypes.includes(mxEv.getType()) &&
continuedTypes.includes(prevEvent.getType());
if (prevEvent !== null
&& prevEvent.sender && mxEv.sender
&& mxEv.sender.userId === prevEvent.sender.userId
&& (mxEv.getType() == prevEvent.getType() || eventTypeContinues)) {
// if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
if (prevEvent !== null && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId &&
(mxEv.getType() === prevEvent.getType() || eventTypeContinues) &&
(mxEv.getTs() - prevEvent.getTs() <= CONTINUATION_MAX_INTERVAL)) {
continuation = true;
}
@ -493,7 +501,7 @@ module.exports = React.createClass({
}
const eventId = mxEv.getId();
const highlight = (eventId == this.props.highlightedEventId);
const highlight = (eventId === this.props.highlightedEventId);
// we can't use local echoes as scroll tokens, because their event IDs change.
// Local echos have a send "status".
@ -632,7 +640,8 @@ module.exports = React.createClass({
render: function() {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const Spinner = sdk.getComponent("elements.Spinner");
let topSpinner, bottomSpinner;
let topSpinner;
let bottomSpinner;
if (this.props.backPaginating) {
topSpinner = <li key="_topSpinner"><Spinner /></li>;
}

View file

@ -70,7 +70,7 @@ export default withMatrixClient(React.createClass({
if (this.state.groups) {
const groupNodes = [];
this.state.groups.forEach((g) => {
groupNodes.push(<GroupTile groupId={g} />);
groupNodes.push(<GroupTile key={g} groupId={g} />);
});
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
content = groupNodes.length > 0 ?
@ -124,7 +124,7 @@ export default withMatrixClient(React.createClass({
) }
</div>
</div>
<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
{/*<div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton>
@ -140,7 +140,7 @@ export default withMatrixClient(React.createClass({
{ 'i': (sub) => <i>{ sub }</i> })
}
</div>
</div>
</div>*/}
</div>
<div className="mx_MyGroups_content">
{ contentHeader }

View file

@ -25,6 +25,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -157,10 +158,12 @@ module.exports = React.createClass({
_onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
},
_onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
},
_onShowDevicesClick: function() {
@ -305,7 +308,26 @@ module.exports = React.createClass({
},
);
} else {
if (
let consentError = null;
for (const m of unsentMessages) {
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
consentError = m.error;
break;
}
}
if (consentError) {
title = _t(
"You can't send any messages until you review and agree to " +
"<consentLink>our terms and conditions</consentLink>.",
{},
{
'consentLink': (sub) =>
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
{ sub }
</a>,
},
);
} else if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
@ -329,11 +351,13 @@ module.exports = React.createClass({
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div>
</div>;
},
@ -350,11 +374,13 @@ module.exports = React.createClass({
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
</div>
</div>
</div>
);

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,30 +16,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
var classNames = require('classnames');
var sdk = require('../../index');
import React from 'react';
import classNames from 'classnames';
import sdk from '../../index';
import { Droppable } from 'react-beautiful-dnd';
import { _t } from '../../languageHandler';
var dis = require('../../dispatcher');
var Unread = require('../../Unread');
var MatrixClientPeg = require('../../MatrixClientPeg');
var RoomNotifs = require('../../RoomNotifs');
var FormattingUtils = require('../../utils/FormattingUtils');
var AccessibleButton = require('../../components/views/elements/AccessibleButton');
import Modal from '../../Modal';
import dis from '../../dispatcher';
import Unread from '../../Unread';
import * as RoomNotifs from '../../RoomNotifs';
import * as FormattingUtils from '../../utils/FormattingUtils';
import { KeyCode } from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
// turn this on for drop & drag console debugging galore
var debug = false;
const debug = false;
const TRUNCATE_AT = 10;
var RoomSubList = React.createClass({
const RoomSubList = React.createClass({
displayName: 'RoomSubList',
debug: debug,
@ -77,8 +73,10 @@ var RoomSubList = React.createClass({
getDefaultProps: function() {
return {
onHeaderClick: function() {}, // NOP
onShowMoreRooms: function() {}, // NOP
onHeaderClick: function() {
}, // NOP
onShowMoreRooms: function() {
}, // NOP
extraTiles: [],
isInvite: false,
};
@ -105,15 +103,17 @@ var RoomSubList = React.createClass({
applySearchFilter: function(list, filter) {
if (filter === "") return list;
return list.filter((room) => {
return room.name && room.name.toLowerCase().indexOf(filter.toLowerCase()) >= 0
});
const lcFilter = filter.toLowerCase();
// case insensitive if room name includes filter,
// or if starts with `#` and one of room's aliases starts with filter
return list.filter((room) => (room.name && room.name.toLowerCase().includes(lcFilter)) ||
(filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter))));
},
// The header is collapsable if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() {
var stuck = this.refs.header.dataset.stuck;
const stuck = this.refs.header.dataset.stuck;
if (this.state.hidden || stuck === undefined || stuck === "none") {
return true;
} else {
@ -139,12 +139,12 @@ var RoomSubList = React.createClass({
onClick: function(ev) {
if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
var isHidden = !this.state.hidden;
this.setState({ hidden : isHidden });
const isHidden = !this.state.hidden;
this.setState({hidden: isHidden});
if (isHidden) {
// as good a way as any to reset the truncate state
this.setState({ truncateAt : TRUNCATE_AT });
this.setState({truncateAt: TRUNCATE_AT});
}
this.props.onShowMoreRooms();
@ -159,7 +159,7 @@ var RoomSubList = React.createClass({
dis.dispatch({
action: 'view_room',
room_id: roomId,
clear_search: (ev && (ev.keyCode == KeyCode.ENTER || ev.keyCode == KeyCode.SPACE)),
clear_search: (ev && (ev.keyCode === KeyCode.ENTER || ev.keyCode === KeyCode.SPACE)),
});
},
@ -169,17 +169,17 @@ var RoomSubList = React.createClass({
},
_shouldShowMentionBadge: function(roomNotifState) {
return roomNotifState != RoomNotifs.MUTE;
return roomNotifState !== RoomNotifs.MUTE;
},
/**
* Total up all the notification counts from the rooms
*
* @param {Number} If supplied will only total notifications for rooms outside the truncation number
* @param {Number} truncateAt If supplied will only total notifications for rooms outside the truncation number
* @returns {Array} The array takes the form [total, highlight] where highlight is a bool
*/
roomNotificationCount: function(truncateAt) {
var self = this;
const self = this;
if (this.props.isInvite) {
return [0, true];
@ -187,9 +187,9 @@ var RoomSubList = React.createClass({
return this.props.list.reduce(function(result, room, index) {
if (truncateAt === undefined || index >= truncateAt) {
var roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
var highlight = room.getUnreadNotificationCount('highlight') > 0;
var notificationCount = room.getUnreadNotificationCount();
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
@ -238,38 +238,83 @@ var RoomSubList = React.createClass({
});
},
_onNotifBadgeClick: function(e) {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
// find first room which has notifications and switch to it
for (const room of this.state.sortedList) {
const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && this._shouldShowMentionBadge(roomNotifState);
if (notifBadges || mentionBadges) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
return;
}
}
},
_onInviteBadgeClick: function(e) {
// prevent the roomsublist collapsing
e.preventDefault();
e.stopPropagation();
// switch to first room in sortedList as that'll be the top of the list for the user
if (this.state.sortedList && this.state.sortedList.length > 0) {
dis.dispatch({
action: 'view_room',
room_id: this.state.sortedList[0].roomId,
});
} else if (this.props.extraTiles && this.props.extraTiles.length > 0) {
// Group Invites are different in that they are all extra tiles and not rooms
// XXX: this is a horrible special case because Group Invite sublist is a hack
if (this.props.extraTiles[0].props && this.props.extraTiles[0].props.group instanceof Group) {
dis.dispatch({
action: 'view_group',
group_id: this.props.extraTiles[0].props.group.groupId,
});
}
}
},
_getHeaderJsx: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
var subListNotifications = this.roomNotificationCount();
var subListNotifCount = subListNotifications[0];
var subListNotifHighlight = subListNotifications[1];
const totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
const roomCount = totalTiles > 0 ? totalTiles : '';
var totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
var roomCount = totalTiles > 0 ? totalTiles : '';
var chevronClasses = classNames({
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden,
});
var badgeClasses = classNames({
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
});
var badge;
let badge;
if (subListNotifCount > 0) {
badge = <div className={badgeClasses}>{ FormattingUtils.formatCount(subListNotifCount) }</div>;
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses}>!</div>;
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
}
// When collapsed, allow a long hover on the header to show user
// the full tag name and room count
var title;
let title;
if (this.props.collapsed) {
title = this.props.label;
if (roomCount !== '') {
@ -277,63 +322,66 @@ var RoomSubList = React.createClass({
}
}
var incomingCall;
let incomingCall;
if (this.props.incomingCall) {
var self = this;
const self = this;
// Check if the incoming call is for this section
var incomingCallRoom = this.props.list.filter(function(room) {
const incomingCallRoom = this.props.list.filter(function(room) {
return self.props.incomingCall.roomId === room.roomId;
});
if (incomingCallRoom.length === 1) {
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>;
const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall =
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
}
var tabindex = this.props.searchFilter === "" ? "0" : "-1";
const tabindex = this.props.searchFilter === "" ? "0" : "-1";
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
{ this.props.collapsed ? '' : this.props.label }
<div className="mx_RoomSubList_roomCount">{ roomCount }</div>
<div className={chevronClasses}></div>
{ badge }
{ incomingCall }
<div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex}>
{this.props.collapsed ? '' : this.props.label}
<div className="mx_RoomSubList_roomCount">{roomCount}</div>
<div className={chevronClasses} />
{badge}
{incomingCall}
</AccessibleButton>
</div>
);
},
_createOverflowTile: function(overflowCount, totalCount) {
var content = <div className="mx_RoomSubList_chevronDown"></div>;
let content = <div className="mx_RoomSubList_chevronDown" />;
var overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
var overflowNotifCount = overflowNotifications[0];
var overflowNotifHighlight = overflowNotifications[1];
const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
const overflowNotifCount = overflowNotifications[0];
const overflowNotifHighlight = overflowNotifications[1];
if (overflowNotifCount && !this.props.collapsed) {
content = FormattingUtils.formatCount(overflowNotifCount);
}
var badgeClasses = classNames({
const badgeClasses = classNames({
'mx_RoomSubList_moreBadge': true,
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
});
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
<div className="mx_RoomSubList_line"></div>
<div className="mx_RoomSubList_more">{ _t("more") }</div>
<div className={ badgeClasses }>{ content }</div>
<div className="mx_RoomSubList_line" />
<div className="mx_RoomSubList_more">{_t("more")}</div>
<div className={badgeClasses}>{content}</div>
</AccessibleButton>
);
},
_showFullMemberList: function() {
this.setState({
truncateAt: -1
truncateAt: -1,
});
this.props.onShowMoreRooms();
@ -341,37 +389,39 @@ var RoomSubList = React.createClass({
},
render: function() {
var connectDropTarget = this.props.connectDropTarget;
var TruncatedList = sdk.getComponent('elements.TruncatedList');
var label = this.props.collapsed ? null : this.props.label;
const TruncatedList = sdk.getComponent('elements.TruncatedList');
let content;
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) {
content = this.props.emptyContent;
if (this.state.sortedList.length === 0 && this.props.extraTiles.length === 0) {
// if no search filter is applied and there is a placeholder defined then show it, otherwise show nothing
if (!this.props.searchFilter && this.props.emptyContent) {
content = this.props.emptyContent;
} else {
// don't show an empty sublist
return null;
}
} else {
content = this.makeRoomTiles();
content.push(...this.props.extraTiles);
}
if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) {
var subList;
var classes = "mx_RoomSubList";
let subList;
const classes = "mx_RoomSubList";
if (!this.state.hidden) {
subList = <TruncatedList className={ classes } truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile} >
{ content }
</TruncatedList>;
}
else {
subList = <TruncatedList className={ classes }>
</TruncatedList>;
subList = <TruncatedList className={classes} truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{content}
</TruncatedList>;
} else {
subList = <TruncatedList className={classes}>
</TruncatedList>;
}
const subListContent = <div>
{ this._getHeaderJsx() }
{ subList }
{this._getHeaderJsx()}
{subList}
</div>;
return this.props.editable ?
@ -379,23 +429,26 @@ var RoomSubList = React.createClass({
droppableId={"room-sub-list-droppable_" + this.props.tagName}
type="draggable-RoomTile"
>
{ (provided, snapshot) => (
{(provided, snapshot) => (
<div ref={provided.innerRef}>
{ subListContent }
{subListContent}
</div>
) }
)}
</Droppable> : subListContent;
}
else {
var Loader = sdk.getComponent("elements.Spinner");
} else {
const Loader = sdk.getComponent("elements.Spinner");
if (this.props.showSpinner) {
content = <Loader />;
}
return (
<div className="mx_RoomSubList">
{ this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined }
{ (this.props.showSpinner && !this.state.hidden) ? <Loader /> : undefined }
{this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined}
{ this.state.hidden ? undefined : content }
</div>
);
}
}
},
});
module.exports = RoomSubList;

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -45,6 +46,7 @@ import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
const DEBUG = false;
let debuglog = function() {};
@ -317,14 +319,7 @@ module.exports = React.createClass({
return false;
}
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
// any valid widget = show apps
for (let i = 0; i < appsStateEvents.length; i++) {
if (appsStateEvents[i].getContent().type && appsStateEvents[i].getContent().url) {
return true;
}
}
return false;
return WidgetUtils.getRoomWidgets(room).length > 0;
},
componentDidMount: function() {
@ -618,9 +613,11 @@ module.exports = React.createClass({
}
},
_updatePreviewUrlVisibility: function(room) {
_updatePreviewUrlVisibility: function({roomId}) {
// URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit
const key = MatrixClientPeg.get().isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled';
this.setState({
showUrlPreview: SettingsStore.getValue("urlPreviewsEnabled", room.roomId),
showUrlPreview: SettingsStore.getValue(key, roomId),
});
},
@ -645,19 +642,23 @@ module.exports = React.createClass({
},
onAccountData: function(event) {
if (event.getType() === "org.matrix.preview_urls" && this.state.room) {
const type = event.getType();
if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) {
// non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls`
this._updatePreviewUrlVisibility(this.state.room);
}
},
onRoomAccountData: function(event, room) {
if (room.roomId == this.state.roomId) {
if (event.getType() === "org.matrix.room.color_scheme") {
const type = event.getType();
if (type === "org.matrix.room.color_scheme") {
const color_scheme = event.getContent();
// XXX: we should validate the event
console.log("Tinter.tint from onRoomAccountData");
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
} else if (event.getType() === "org.matrix.room.preview_urls") {
} else if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") {
// non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls`
this._updatePreviewUrlVisibility(room);
}
}
@ -913,6 +914,8 @@ module.exports = React.createClass({
},
uploadFile: async function(file) {
dis.dispatch({action: 'focus_composer'});
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'});
return;

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2017, 2018 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,6 +26,7 @@ import dis from '../../dispatcher';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
const TagPanel = React.createClass({
displayName: 'TagPanel',
@ -84,7 +85,10 @@ const TagPanel = React.createClass({
},
onMouseDown(e) {
dis.dispatch({action: 'deselect_tags'});
// only dispatch if its not a no-op
if (this.state.selectedTags.length > 0) {
dis.dispatch({action: 'deselect_tags'});
}
},
onCreateGroupClick(ev) {
@ -113,17 +117,26 @@ const TagPanel = React.createClass({
/>;
});
const clearButton = this.state.selectedTags.length > 0 ?
<TintableSvg src="img/icons-close.svg" width="24" height="24"
alt={_t("Clear filter")}
title={_t("Clear filter")}
/> :
<div />;
const itemsSelected = this.state.selectedTags.length > 0;
return <div className="mx_TagPanel">
<AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
let clearButton;
if (itemsSelected) {
clearButton = <AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
<TintableSvg src="img/icons-close.svg" width="24" height="24"
alt={_t("Clear filter")}
title={_t("Clear filter")}
/>
</AccessibleButton>;
}
const classes = classNames('mx_TagPanel', {
mx_TagPanel_items_selected: itemsSelected,
});
return <div className={classes}>
<div className="mx_TagPanel_clearButton_container">
{ clearButton }
</AccessibleButton>
</div>
<div className="mx_TagPanel_divider" />
<GeminiScrollbarWrapper
className="mx_TagPanel_scroller"

View file

@ -298,6 +298,7 @@ module.exports = React.createClass({
if (this._unmounted) return;
this.setState({
mediaDevices,
activeAudioOutput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audiooutput'),
activeAudioInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audioinput'),
activeVideoInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_videoinput'),
});
@ -428,7 +429,6 @@ module.exports = React.createClass({
"push notifications on other devices until you log back in to them",
) + ".",
});
dis.dispatch({action: 'password_changed'});
},
_onAddEmailEditFinished: function(value, shouldSubmit) {
@ -976,6 +976,11 @@ module.exports = React.createClass({
return devices.map((device) => <span key={device.deviceId}>{ device.label }</span>);
},
_setAudioOutput: function(deviceId) {
this.setState({activeAudioOutput: deviceId});
CallMediaHandler.setAudioOutput(deviceId);
},
_setAudioInput: function(deviceId) {
this.setState({activeAudioInput: deviceId});
CallMediaHandler.setAudioInput(deviceId);
@ -1016,6 +1021,7 @@ module.exports = React.createClass({
const Dropdown = sdk.getComponent('elements.Dropdown');
let speakerDropdown = <p>{ _t('No Audio Outputs detected') }</p>;
let microphoneDropdown = <p>{ _t('No Microphones detected') }</p>;
let webcamDropdown = <p>{ _t('No Webcams detected') }</p>;
@ -1024,6 +1030,26 @@ module.exports = React.createClass({
label: _t('Default Device'),
};
const audioOutputs = this.state.mediaDevices.audiooutput.slice(0);
if (audioOutputs.length > 0) {
let defaultOutput = '';
if (!audioOutputs.some((input) => input.deviceId === 'default')) {
audioOutputs.unshift(defaultOption);
} else {
defaultOutput = 'default';
}
speakerDropdown = <div>
<h4>{ _t('Audio Output') }</h4>
<Dropdown
className="mx_UserSettings_webRtcDevices_dropdown"
value={this.state.activeAudioOutput || defaultOutput}
onOptionChange={this._setAudioOutput}>
{ this._mapWebRtcDevicesToSpans(audioOutputs) }
</Dropdown>
</div>;
}
const audioInputs = this.state.mediaDevices.audioinput.slice(0);
if (audioInputs.length > 0) {
let defaultInput = '';
@ -1065,8 +1091,9 @@ module.exports = React.createClass({
}
return <div>
{ microphoneDropdown }
{ webcamDropdown }
{ speakerDropdown }
{ microphoneDropdown }
{ webcamDropdown }
</div>;
},
@ -1080,6 +1107,14 @@ module.exports = React.createClass({
</div>;
},
onSelfShareClick: function() {
const cli = MatrixClientPeg.get();
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share self dialog', '', ShareDialog, {
target: cli.getUser(this._me),
});
},
_showSpoiler: function(event) {
const target = event.target;
target.innerHTML = target.getAttribute('data-spoiler');
@ -1301,10 +1336,13 @@ module.exports = React.createClass({
<div className="mx_UserSettings_section">
<div className="mx_UserSettings_advanced">
{ _t("Logged in as:") } { this._me }
{ _t("Logged in as:") + ' ' }
<a onClick={this.onSelfShareClick} className="mx_UserSettings_link">
{ this._me }
</a>
</div>
<div className="mx_UserSettings_advanced">
{ _t('Access Token:') }
{ _t('Access Token:') + ' ' }
<span className="mx_UserSettings_advanced_spoiler"
onClick={this._showSpoiler}
data-spoiler={MatrixClientPeg.get().getAccessToken()}>

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
@ -45,6 +43,8 @@ module.exports = React.createClass({
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
progress: null,
password: null,
password2: null,
};
},
@ -103,7 +103,7 @@ module.exports = React.createClass({
</div>,
button: _t('Continue'),
extraButtons: [
<button className="mx_Dialog_primary"
<button key="export_keys" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}>
{ _t('Export E2E room keys') }
</button>,
@ -169,7 +169,8 @@ module.exports = React.createClass({
} else if (this.state.progress === "sent_email") {
resetPasswordJsx = (
<div className="mx_Login_prompt">
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", { emailAddress: this.state.email }) }
{ _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, " +
"click below.", { emailAddress: this.state.email }) }
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
value={_t('I have verified my email address')} />
@ -179,14 +180,15 @@ module.exports = React.createClass({
resetPasswordJsx = (
<div className="mx_Login_prompt">
<p>{ _t('Your password has been reset') }.</p>
<p>{ _t('You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device') }.</p>
<p>{ _t('You have been logged out of all devices and will no longer receive push notifications. ' +
'To re-enable notifications, sign in again on each device') }.</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value={_t('Return to login screen')} />
</div>
);
} else {
let serverConfigSection;
if (!SdkConfig.get().disable_custom_urls) {
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
@ -199,6 +201,8 @@ module.exports = React.createClass({
);
}
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
resetPasswordJsx = (
<div>
<div className="mx_Login_prompt">
@ -233,6 +237,7 @@ module.exports = React.createClass({
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
{ _t('Create an account') }
</a>
<LanguageSelector />
<LoginFooter />
</div>
</div>

View file

@ -0,0 +1,38 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SdkConfig from "../../../SdkConfig";
import {getCurrentLanguage} from "../../../languageHandler";
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import PlatformPeg from "../../../PlatformPeg";
import sdk from '../../../index';
import React from 'react';
function onChange(newLang) {
if (getCurrentLanguage() !== newLang) {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
}
}
export default function LanguageSelector() {
if (SdkConfig.get()['disable_login_language_selector']) return <div />;
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
return <div className="mx_Login_language_div">
<LanguageDropdown onOptionChange={onChange} className="mx_Login_language" value={getCurrentLanguage()} />
</div>;
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,15 +21,13 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index';
import Login from '../../../Login';
import PlatformPeg from '../../../PlatformPeg';
import SdkConfig from '../../../SdkConfig';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import SettingsStore from "../../../settings/SettingsStore";
// For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/;
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
/**
* A wire component which glues together login UI components and Login logic
@ -94,6 +93,13 @@ module.exports = React.createClass({
this._unmounted = true;
},
onPasswordLoginError: function(errorText) {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
this.setState({
busy: true,
@ -113,10 +119,10 @@ module.exports = React.createClass({
// Some error strings only apply for logging in
const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus == 400 && usingEmail) {
if (error.httpStatus === 400 && usingEmail) {
errorText = _t('This Home Server does not support login using email address.');
} else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (SdkConfig.get().disable_custom_urls) {
if (SdkConfig.get()['disable_custom_urls']) {
errorText = (
<div>
<div>{ _t('Incorrect username and/or password.') }</div>
@ -143,7 +149,7 @@ module.exports = React.createClass({
// but the login API gives a 403 https://matrix.org/jira/browse/SYN-744
// mentions this (although the bug is for UI auth which is not this)
// We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403,
loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
});
}).finally(() => {
if (this._unmounted) {
@ -231,7 +237,7 @@ module.exports = React.createClass({
hsUrl = hsUrl || this.state.enteredHomeserverUrl;
isUrl = isUrl || this.state.enteredIdentityServerUrl;
const fallbackHsUrl = hsUrl == this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null;
const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
@ -310,19 +316,27 @@ module.exports = React.createClass({
!this.state.enteredHomeserverUrl.startsWith("http"))
) {
errorText = <span>
{
_t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.",
{},
{ 'a': (sub) => { return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">{ sub }</a>; } },
{ _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", {},
{
'a': (sub) => {
return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">
{ sub }
</a>;
},
},
) }
</span>;
} else {
errorText = <span>
{
_t("Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.",
{},
{ 'a': (sub) => { return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>; } },
{ _t("Can't connect to homeserver - please check your connectivity, ensure your " +
"<a>homeserver's SSL certificate</a> is trusted, and that a browser extension " +
"is not blocking requests.", {},
{
'a': (sub) => {
return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>;
},
},
) }
</span>;
}
@ -350,6 +364,7 @@ module.exports = React.createClass({
return (
<PasswordLogin
onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber}
@ -370,23 +385,6 @@ module.exports = React.createClass({
);
},
_onLanguageChange: function(newLang) {
if (languageHandler.getCurrentLanguage() !== newLang) {
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload();
}
},
_renderLanguageSetting: function() {
const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown');
return <div className="mx_Login_language_div">
<LanguageDropdown onOptionChange={this._onLanguageChange}
className="mx_Login_language"
value={languageHandler.getCurrentLanguage()}
/>
</div>;
},
render: function() {
const Loader = sdk.getComponent("elements.Spinner");
const LoginPage = sdk.getComponent("login.LoginPage");
@ -403,21 +401,10 @@ module.exports = React.createClass({
</a>;
}
let returnToAppJsx;
/*
// with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) {
returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
{ _t('Return to app') }
</a>;
}
*/
let serverConfig;
let header;
if (!SdkConfig.get().disable_custom_urls) {
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfig = <ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
@ -447,6 +434,8 @@ module.exports = React.createClass({
);
}
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
return (
<LoginPage>
<div className="mx_Login_box">
@ -460,8 +449,7 @@ module.exports = React.createClass({
{ _t('Create an account') }
</a>
{ loginAsGuestJsx }
{ returnToAppJsx }
{ !SdkConfig.get().disable_login_language_selector ? this._renderLanguageSetting() : '' }
<LanguageSelector />
<LoginFooter />
</div>
</div>

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -22,7 +23,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import ServerConfig from '../../views/login/ServerConfig';
import MatrixClientPeg from '../../../MatrixClientPeg';
import RegistrationForm from '../../views/login/RegistrationForm';
import RtsClient from '../../../RtsClient';
@ -62,6 +62,12 @@ module.exports = React.createClass({
onLoginClick: PropTypes.func.isRequired,
onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
rtsClient: PropTypes.shape({
getTeamsConfig: PropTypes.func.isRequired,
trackReferral: PropTypes.func.isRequired,
getTeam: PropTypes.func.isRequired,
}),
},
getInitialState: function() {
@ -133,7 +139,7 @@ module.exports = React.createClass({
newState.isUrl = config.isUrl;
}
this.props.onServerConfigChange(config);
this.setState(newState, function() {
this.setState(newState, () => {
this._replaceClient();
});
},
@ -159,11 +165,11 @@ module.exports = React.createClass({
let msg = response.message || response.toString();
// can we give a better error message?
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdn_available = false;
let msisdnAvailable = false;
for (const flow of response.available_flows) {
msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1;
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
}
if (!msisdn_available) {
if (!msisdnAvailable) {
msg = _t('This server does not support authentication with a phone number.');
}
}
@ -242,7 +248,7 @@ module.exports = React.createClass({
return matrixClient.getPushers().then((resp)=>{
const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email') {
if (pushers[i].kind === 'email') {
const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).done(() => {
@ -267,7 +273,7 @@ module.exports = React.createClass({
errMsg = _t('Passwords don\'t match.');
break;
case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH: MIN_PASSWORD_LENGTH});
errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH});
break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = _t('This doesn\'t look like a valid email address.');
@ -353,7 +359,7 @@ module.exports = React.createClass({
registerBody = <Spinner />;
} else {
let serverConfigSection;
if (!SdkConfig.get().disable_custom_urls) {
if (!SdkConfig.get()['disable_custom_urls']) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
@ -385,18 +391,6 @@ module.exports = React.createClass({
);
}
let returnToAppJsx;
/*
// with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) {
returnToAppJsx = (
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
{ _t('Return to app') }
</a>
);
}
*/
let header;
let errorText;
// FIXME: remove hardcoded Status team tweaks at some point
@ -418,6 +412,8 @@ module.exports = React.createClass({
);
}
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
return (
<LoginPage>
<div className="mx_Login_box">
@ -431,7 +427,7 @@ module.exports = React.createClass({
{ registerBody }
{ signIn }
{ errorText }
{ returnToAppJsx }
<LanguageSelector />
<LoginFooter />
</div>
</LoginPage>

View file

@ -0,0 +1,87 @@
/*
Copyright 2018 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import {Group} from 'matrix-js-sdk';
import GroupStore from "../../../stores/GroupStore";
export default class GroupInviteTileContextMenu extends React.Component {
static propTypes = {
group: PropTypes.instanceOf(Group).isRequired,
/* callback called when the menu is dismissed */
onFinished: PropTypes.func,
};
constructor(props, context) {
super(props, context);
this._onClickReject = this._onClickReject.bind(this);
}
componentWillMount() {
this._unmounted = false;
}
componentWillUnmount() {
this._unmounted = true;
}
_onClickReject() {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Reject community invite', '', QuestionDialog, {
title: _t('Reject invitation'),
description: _t('Are you sure you want to reject the invitation?'),
onFinished: async (shouldLeave) => {
if (!shouldLeave) return;
// FIXME: controller shouldn't be loading a view :(
const Loader = sdk.getComponent("elements.Spinner");
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
try {
await GroupStore.leaveGroup(this.props.group.groupId);
} catch (e) {
console.error("Error rejecting community invite: ", e);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error rejecting invite', '', ErrorDialog, {
title: _t("Error"),
description: _t("Unable to reject invite"),
});
} finally {
modal.close();
}
},
});
// Close the context menu
if (this.props.onFinished) {
this.props.onFinished();
}
}
render() {
return <div>
<div className="mx_RoomTileContextMenu_leave" onClick={this._onClickReject} >
<img className="mx_RoomTileContextMenu_tag_icon" src="img/icon_context_delete.svg" width="15" height="15" />
{ _t('Reject') }
</div>
</div>;
}
}

View file

@ -15,10 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk';
import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher';
@ -184,6 +183,15 @@ module.exports = React.createClass({
this.closeMenu();
},
onPermalinkClick: function(e: Event) {
e.preventDefault();
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room message dialog', '', ShareDialog, {
target: this.props.mxEvent,
});
this.closeMenu();
},
onReplyClick: function() {
dis.dispatch({
action: 'reply_to_event',
@ -211,7 +219,10 @@ module.exports = React.createClass({
let replyButton;
let collapseReplyThread;
if (eventStatus === 'not_sent') {
// status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') }
@ -219,7 +230,7 @@ module.exports = React.createClass({
);
}
if (!eventStatus && this.state.canRedact) {
if (isSent && this.state.canRedact) {
redactButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
{ _t('Remove') }
@ -227,7 +238,7 @@ module.exports = React.createClass({
);
}
if (eventStatus === "queued" || eventStatus === "not_sent") {
if (eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT) {
cancelButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') }
@ -235,7 +246,7 @@ module.exports = React.createClass({
);
}
if (!eventStatus && this.props.mxEvent.getType() === 'm.room.message') {
if (isSent && this.props.mxEvent.getType() === 'm.room.message') {
const content = this.props.mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = (
@ -244,13 +255,11 @@ module.exports = React.createClass({
</div>
);
if (SettingsStore.isFeatureEnabled("feature_rich_quoting")) {
replyButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
{ _t('Reply') }
</div>
);
}
replyButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
{ _t('Reply') }
</div>
);
if (this.state.canPin) {
pinButton = (
@ -290,7 +299,7 @@ module.exports = React.createClass({
const permalinkButton = (
<div className="mx_MessageContextMenu_field">
<a href={makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId())}
target="_blank" rel="noopener" onClick={this.closeMenu}>{ _t('Permalink') }</a>
target="_blank" rel="noopener" onClick={this.onPermalinkClick}>{ _t('Share Message') }</a>
</div>
);

View file

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,7 +17,7 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Promise from 'bluebird';
@ -27,6 +27,13 @@ import GroupStore from '../../../stores/GroupStore';
const TRUNCATE_QUERY_LIST = 40;
const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200;
const addressTypeName = {
'mx-user-id': _td("Matrix ID"),
'mx-room-id': _td("Matrix Room ID"),
'email': _td("email address"),
};
module.exports = React.createClass({
displayName: "AddressPickerDialog",
@ -66,7 +73,7 @@ module.exports = React.createClass({
// List of UserAddressType objects representing
// the list of addresses we're going to invite
userList: [],
selectedList: [],
// Whether a search is ongoing
busy: false,
@ -76,10 +83,9 @@ module.exports = React.createClass({
serverSupportsUserDirectory: true,
// The query being searched for
query: "",
// List of UserAddressType objects representing
// the set of auto-completion results for the current search
// query.
queryList: [],
// List of UserAddressType objects representing the set of
// auto-completion results for the current search query.
suggestedList: [],
};
},
@ -91,14 +97,14 @@ module.exports = React.createClass({
},
onButtonClick: function() {
let userList = this.state.userList.slice();
let selectedList = this.state.selectedList.slice();
// Check the text input field to see if user has an unconverted address
// If there is and it's valid add it to the local userList
// If there is and it's valid add it to the local selectedList
if (this.refs.textinput.value !== '') {
userList = this._addInputToList();
if (userList === null) return;
selectedList = this._addInputToList();
if (selectedList === null) return;
}
this.props.onFinished(true, userList);
this.props.onFinished(true, selectedList);
},
onCancel: function() {
@ -118,18 +124,18 @@ module.exports = React.createClass({
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.moveSelectionDown();
} else if (this.state.queryList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab
} else if (this.state.suggestedList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab
e.stopPropagation();
e.preventDefault();
if (this.addressSelector) this.addressSelector.chooseSelection();
} else if (this.refs.textinput.value.length === 0 && this.state.userList.length && e.keyCode === 8) { // backspace
} else if (this.refs.textinput.value.length === 0 && this.state.selectedList.length && e.keyCode === 8) { // backspace
e.stopPropagation();
e.preventDefault();
this.onDismissed(this.state.userList.length - 1)();
this.onDismissed(this.state.selectedList.length - 1)();
} else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
if (this.refs.textinput.value == '') {
if (this.refs.textinput.value === '') {
// if there's nothing in the input box, submit the form
this.onButtonClick();
} else {
@ -148,7 +154,7 @@ module.exports = React.createClass({
clearTimeout(this.queryChangedDebouncer);
}
// Only do search if there is something to search
if (query.length > 0 && query != '@' && query.length >= 2) {
if (query.length > 0 && query !== '@' && query.length >= 2) {
this.queryChangedDebouncer = setTimeout(() => {
if (this.props.pickerType === 'user') {
if (this.props.groupId) {
@ -170,7 +176,7 @@ module.exports = React.createClass({
}, QUERY_USER_DIRECTORY_DEBOUNCE_MS);
} else {
this.setState({
queryList: [],
suggestedList: [],
query: "",
searchError: null,
});
@ -179,11 +185,11 @@ module.exports = React.createClass({
onDismissed: function(index) {
return () => {
const userList = this.state.userList.slice();
userList.splice(index, 1);
const selectedList = this.state.selectedList.slice();
selectedList.splice(index, 1);
this.setState({
userList: userList,
queryList: [],
selectedList,
suggestedList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
@ -197,11 +203,11 @@ module.exports = React.createClass({
},
onSelected: function(index) {
const userList = this.state.userList.slice();
userList.push(this.state.queryList[index]);
const selectedList = this.state.selectedList.slice();
selectedList.push(this.state.suggestedList[index]);
this.setState({
userList: userList,
queryList: [],
selectedList,
suggestedList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
@ -379,10 +385,10 @@ module.exports = React.createClass({
},
_processResults: function(results, query) {
const queryList = [];
const suggestedList = [];
results.forEach((result) => {
if (result.room_id) {
queryList.push({
suggestedList.push({
addressType: 'mx-room-id',
address: result.room_id,
displayName: result.name,
@ -399,7 +405,7 @@ module.exports = React.createClass({
// Return objects, structure of which is defined
// by UserAddressType
queryList.push({
suggestedList.push({
addressType: 'mx-user-id',
address: result.user_id,
displayName: result.display_name,
@ -413,18 +419,18 @@ module.exports = React.createClass({
// a perfectly valid address if there are close matches.
const addrType = getAddressType(query);
if (this.props.validAddressTypes.includes(addrType)) {
queryList.unshift({
suggestedList.unshift({
addressType: addrType,
address: query,
isKnown: false,
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
if (addrType == 'email') {
if (addrType === 'email') {
this._lookupThreepid(addrType, query).done();
}
}
this.setState({
queryList,
suggestedList,
error: false,
}, () => {
if (this.addressSelector) this.addressSelector.moveSelectionTop();
@ -442,14 +448,14 @@ module.exports = React.createClass({
if (!this.props.validAddressTypes.includes(addrType)) {
this.setState({ error: true });
return null;
} else if (addrType == 'mx-user-id') {
} else if (addrType === 'mx-user-id') {
const user = MatrixClientPeg.get().getUser(addrObj.address);
if (user) {
addrObj.displayName = user.displayName;
addrObj.avatarMxc = user.avatarUrl;
addrObj.isKnown = true;
}
} else if (addrType == 'mx-room-id') {
} else if (addrType === 'mx-room-id') {
const room = MatrixClientPeg.get().getRoom(addrObj.address);
if (room) {
addrObj.displayName = room.name;
@ -458,15 +464,15 @@ module.exports = React.createClass({
}
}
const userList = this.state.userList.slice();
userList.push(addrObj);
const selectedList = this.state.selectedList.slice();
selectedList.push(addrObj);
this.setState({
userList: userList,
queryList: [],
selectedList,
suggestedList: [],
query: "",
});
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
return userList;
return selectedList;
},
_lookupThreepid: function(medium, address) {
@ -492,7 +498,7 @@ module.exports = React.createClass({
if (res === null) return null;
if (cancelled) return null;
this.setState({
queryList: [{
suggestedList: [{
// a UserAddressType
addressType: medium,
address: address,
@ -510,15 +516,27 @@ module.exports = React.createClass({
const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
// map addressType => set of addresses to avoid O(n*m) operation
const selectedAddresses = {};
this.state.selectedList.forEach(({address, addressType}) => {
if (!selectedAddresses[addressType]) selectedAddresses[addressType] = new Set();
selectedAddresses[addressType].add(address);
});
// Filter out any addresses in the above already selected addresses (matching both type and address)
const filteredSuggestedList = this.state.suggestedList.filter(({address, addressType}) => {
return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address));
});
const query = [];
// create the invite list
if (this.state.userList.length > 0) {
if (this.state.selectedList.length > 0) {
const AddressTile = sdk.getComponent("elements.AddressTile");
for (let i = 0; i < this.state.userList.length; i++) {
for (let i = 0; i < this.state.selectedList.length; i++) {
query.push(
<AddressTile
key={i}
address={this.state.userList[i]}
address={this.state.selectedList[i]}
canDismiss={true}
onDismissed={this.onDismissed(i)}
showAddress={this.props.pickerType === 'user'} />,
@ -528,7 +546,7 @@ module.exports = React.createClass({
// Add the query at the end
query.push(
<textarea key={this.state.userList.length}
<textarea key={this.state.selectedList.length}
rows="1"
id="textinput"
ref="textinput"
@ -543,34 +561,22 @@ module.exports = React.createClass({
let error;
let addressSelector;
if (this.state.error) {
let tryUsing = '';
const validTypeDescriptions = this.props.validAddressTypes.map((t) => {
return {
'mx-user-id': _t("Matrix ID"),
'mx-room-id': _t("Matrix Room ID"),
'email': _t("email address"),
}[t];
});
tryUsing = _t("Try using one of the following valid address types: %(validTypesList)s.", {
validTypesList: validTypeDescriptions.join(", "),
});
const validTypeDescriptions = this.props.validAddressTypes.map((t) => _t(addressTypeName[t]));
error = <div className="mx_ChatInviteDialog_error">
{ _t("You have entered an invalid address.") }
<br />
{ tryUsing }
{ _t("Try using one of the following valid address types: %(validTypesList)s.", {
validTypesList: validTypeDescriptions.join(", "),
}) }
</div>;
} else if (this.state.searchError) {
error = <div className="mx_ChatInviteDialog_error">{ this.state.searchError }</div>;
} else if (
this.state.query.length > 0 &&
this.state.queryList.length === 0 &&
!this.state.busy
) {
} else if (this.state.query.length > 0 && filteredSuggestedList.length === 0 && !this.state.busy) {
error = <div className="mx_ChatInviteDialog_error">{ _t("No results") }</div>;
} else {
addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}}
addressList={this.state.queryList}
addressList={filteredSuggestedList}
showAddress={this.props.pickerType === 'user'}
onSelected={this.onSelected}
truncateAt={TRUNCATE_QUERY_LIST}

View file

@ -18,6 +18,7 @@ limitations under the License.
import React from 'react';
import FocusTrap from 'focus-trap-react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { MatrixClient } from 'matrix-js-sdk';
@ -64,7 +65,10 @@ export default React.createClass({
// Id of content element
// If provided, this is used to add a aria-describedby attribute
contentId: React.PropTypes.string,
contentId: PropTypes.string,
// optional additional class for the title element
titleClass: PropTypes.string,
},
getDefaultProps: function() {
@ -105,25 +109,28 @@ export default React.createClass({
render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
let cancelButton;
if (this.props.hasCancel) {
cancelButton = <AccessibleButton onClick={this._onCancelClick} className="mx_Dialog_cancelButton">
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</AccessibleButton>;
}
return (
<FocusTrap onKeyDown={this._onKeyDown}
className={this.props.className}
role="dialog"
aria-labelledby='mx_BaseDialog_title'
// This should point to a node describing the dialog.
// If we were about to completelly follow this recommendation we'd need to
// If we were about to completely follow this recommendation we'd need to
// make all the components relying on BaseDialog to be aware of it.
// So instead we will use the whole content as the description.
// Description comes first and if the content contains more text,
// AT users can skip its presentation.
aria-describedby={this.props.contentId}
>
{ this.props.hasCancel ? <AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton"
>
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</AccessibleButton> : null }
<div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'>
{ cancelButton }
<div className={classNames('mx_Dialog_title', this.props.titleClass)} id='mx_BaseDialog_title'>
{ this.props.title }
</div>
{ this.props.children }

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -28,6 +29,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
constructor(props) {
super(props);
this.onFinished = this.onFinished.bind(this);
this.onRoomTileClick = this.onRoomTileClick.bind(this);
this.state = {
@ -53,10 +55,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
const room = client.getRoom(roomId);
if (room) {
const me = room.getMember(client.credentials.userId);
const highlight = (
room.getUnreadNotificationCount('highlight') > 0 ||
me.membership == "invite"
);
const highlight = room.getUnreadNotificationCount('highlight') > 0 || me.membership === "invite";
tiles.push(
<RoomTile key={room.roomId} room={room}
transparent={true}
@ -64,7 +63,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)}
highlight={highlight}
isInvite={me.membership == "invite"}
isInvite={me.membership === "invite"}
onClick={this.onRoomTileClick}
/>,
);
@ -110,6 +109,10 @@ export default class ChatCreateOrReuseDialog extends React.Component {
this.props.onExistingRoomSelected(roomId);
}
onFinished() {
this.props.onFinished(false);
}
render() {
let title = '';
let content = null;
@ -170,14 +173,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
{ profile }
</div>
<DialogButtons primaryButton={_t('Start Chatting')}
onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
onPrimaryButtonClick={this.props.onNewDMClick} focus={true} />
</div>;
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_ChatCreateOrReuseDialog'
onFinished={this.props.onFinished.bind(false)}
onFinished={this.onFinished}
title={title}
contentId='mx_Dialog_content'
>
@ -187,7 +190,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
}
}
ChatCreateOrReuseDialog.propTyps = {
ChatCreateOrReuseDialog.propTypes = {
userId: PropTypes.string.isRequired,
// Called when clicking outside of the dialog
onFinished: PropTypes.func.isRequired,

View file

@ -52,8 +52,8 @@ export default React.createClass({
<div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
<div className="mx_CreateRoomDialog_input_container">
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} />
</div>
<br />

View file

@ -67,9 +67,10 @@ export default React.createClass({
{ this.props.description }
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}
cancelButton={this.props.cancelButton}
onPrimaryButtonClick={this.onOk}
primaryButtonClass={primaryButtonClass}
cancelButton={this.props.cancelButton}
hasCancel={this.props.hasCancelButton}
onPrimaryButtonClick={this.onOk}
focus={this.props.focus}
onCancel={this.onCancel}
>

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -36,7 +37,7 @@ export default React.createClass({
getInitialState: function() {
return {
emailAddress: null,
emailAddress: '',
emailBusy: false,
};
},
@ -127,6 +128,7 @@ export default React.createClass({
const EditableText = sdk.getComponent('elements.EditableText');
const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText
initialValue={this.state.emailAddress}
className="mx_SetEmailDialog_email_input"
autoFocus="true"
placeholder={_t("Email address")}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -79,15 +80,11 @@ export default React.createClass({
Modal.createDialog(WarmFuzzy, {
didSetEmail: res.didSetEmail,
onFinished: () => {
this._onContinueClicked();
this.props.onFinished();
},
});
},
_onContinueClicked: function() {
this.props.onFinished(true);
},
_onPasswordChangeError: function(err) {
let errMsg = err.error || "";
if (err.httpStatus === 403) {

View file

@ -0,0 +1,224 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {Room, User, Group, RoomMember, MatrixEvent} from 'matrix-js-sdk';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import QRCode from 'qrcode-react';
import {makeEventPermalink, makeGroupPermalink, makeRoomPermalink, makeUserPermalink} from "../../../matrix-to";
import * as ContextualMenu from "../../structures/ContextualMenu";
const socials = [
{
name: 'Facebook',
img: 'img/social/facebook.png',
url: (url) => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
}, {
name: 'Twitter',
img: 'img/social/twitter-2.png',
url: (url) => `https://twitter.com/home?status=${url}`,
}, /* // icon missing
name: 'Google Plus',
img: 'img/social/',
url: (url) => `https://plus.google.com/share?url=${url}`,
},*/ {
name: 'LinkedIn',
img: 'img/social/linkedin.png',
url: (url) => `https://www.linkedin.com/shareArticle?mini=true&url=${url}`,
}, {
name: 'Reddit',
img: 'img/social/reddit.png',
url: (url) => `http://www.reddit.com/submit?url=${url}`,
}, {
name: 'email',
img: 'img/social/email-1.png',
url: (url) => `mailto:?body=${url}`,
},
];
export default class ShareDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
target: PropTypes.oneOfType([
PropTypes.instanceOf(Room),
PropTypes.instanceOf(User),
PropTypes.instanceOf(Group),
PropTypes.instanceOf(RoomMember),
PropTypes.instanceOf(MatrixEvent),
]).isRequired,
};
constructor(props) {
super(props);
this.onCopyClick = this.onCopyClick.bind(this);
this.onLinkSpecificEventCheckboxClick = this.onLinkSpecificEventCheckboxClick.bind(this);
this.state = {
// MatrixEvent defaults to share linkSpecificEvent
linkSpecificEvent: this.props.target instanceof MatrixEvent,
};
}
static _selectText(target) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
static onLinkClick(e) {
e.preventDefault();
const {target} = e;
ShareDialog._selectText(target);
}
onCopyClick(e) {
e.preventDefault();
ShareDialog._selectText(this.refs.link);
let successful;
try {
successful = document.execCommand('copy');
} catch (err) {
console.error('Failed to copy: ', err);
}
const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
const buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
const {close} = ContextualMenu.createMenu(GenericTextContextMenu, {
chevronOffset: 10,
left: x,
top: y,
message: successful ? _t('Copied!') : _t('Failed to copy'),
}, false);
e.target.onmouseleave = close;
}
onLinkSpecificEventCheckboxClick() {
this.setState({
linkSpecificEvent: !this.state.linkSpecificEvent,
});
}
render() {
let title;
let matrixToUrl;
let checkbox;
if (this.props.target instanceof Room) {
title = _t('Share Room');
const events = this.props.target.getLiveTimeline().getEvents();
if (events.length > 0) {
checkbox = <div>
<input type="checkbox"
id="mx_ShareDialog_checkbox"
checked={this.state.linkSpecificEvent}
onClick={this.onLinkSpecificEventCheckboxClick} />
<label htmlFor="mx_ShareDialog_checkbox">
{ _t('Link to most recent message') }
</label>
</div>;
}
if (this.state.linkSpecificEvent) {
matrixToUrl = makeEventPermalink(this.props.target.roomId, events[events.length - 1].getId());
} else {
matrixToUrl = makeRoomPermalink(this.props.target.roomId);
}
} else if (this.props.target instanceof User || this.props.target instanceof RoomMember) {
title = _t('Share User');
matrixToUrl = makeUserPermalink(this.props.target.userId);
} else if (this.props.target instanceof Group) {
title = _t('Share Community');
matrixToUrl = makeGroupPermalink(this.props.target.groupId);
} else if (this.props.target instanceof MatrixEvent) {
title = _t('Share Room Message');
checkbox = <div>
<input type="checkbox"
id="mx_ShareDialog_checkbox"
checked={this.state.linkSpecificEvent}
onClick={this.onLinkSpecificEventCheckboxClick} />
<label htmlFor="mx_ShareDialog_checkbox">
{ _t('Link to selected message') }
</label>
</div>;
if (this.state.linkSpecificEvent) {
matrixToUrl = makeEventPermalink(this.props.target.getRoomId(), this.props.target.getId());
} else {
matrixToUrl = makeRoomPermalink(this.props.target.getRoomId());
}
}
const encodedUrl = encodeURIComponent(matrixToUrl);
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return <BaseDialog title={title}
className='mx_ShareDialog'
contentId='mx_Dialog_content'
onFinished={this.props.onFinished}
>
<div className="mx_ShareDialog_content">
<div className="mx_ShareDialog_matrixto">
<a ref="link"
href={matrixToUrl}
onClick={ShareDialog.onLinkClick}
className="mx_ShareDialog_matrixto_link"
>
{ matrixToUrl }
</a>
<a href={matrixToUrl} className="mx_ShareDialog_matrixto_copy" onClick={this.onCopyClick}>
{ _t('COPY') }
<div>&nbsp;</div>
</a>
</div>
{ checkbox }
<hr />
<div className="mx_ShareDialog_split">
<div className="mx_ShareDialog_qrcode_container">
<QRCode value={matrixToUrl} size={256} logoWidth={48} logo="img/matrix-m.svg" />
</div>
<div className="mx_ShareDialog_social_container">
{
socials.map((social) => <a rel="noopener"
target="_blank"
key={social.name}
name={social.name}
href={social.url(encodedUrl)}
className="mx_ShareDialog_social_icon"
>
<img src={social.img} alt={social.name} height={64} width={64} />
</a>)
}
</div>
</div>
</div>
</BaseDialog>;
}
}

View file

@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import url from 'url';
import { _t } from '../../../languageHandler';
import WidgetUtils from "../../../WidgetUtils";
import WidgetUtils from "../../../utils/WidgetUtils";
export default class AppPermission extends React.Component {
constructor(props) {

View file

@ -31,7 +31,7 @@ import sdk from '../../../index';
import AppPermission from './AppPermission';
import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner';
import WidgetUtils from '../../../WidgetUtils';
import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher';
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
@ -319,10 +319,9 @@ export default class AppTile extends React.Component {
return;
}
this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent(
WidgetUtils.setRoomWidget(
this.props.room.roomId,
'im.vector.modular.widgets',
{}, // empty content
this.props.id,
).catch((e) => {
console.error('Failed to delete widget', e);

View file

@ -139,8 +139,11 @@ module.exports = React.createClass({
</div>
{ editableItems }
{ this.props.canEdit ?
// This is slightly evil; we want a new instance of
// EditableItem when the list grows. To make sure it's
// reset to its initial state.
<EditableItem
key={-1}
key={editableItems.length}
initialValue={this.props.newItem}
onAdd={this.onItemAdded}
onChange={this.onNewItemChanged}

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,15 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
const React = require('react');
import React from 'react';
import PropTypes from 'prop-types';
const KEY_TAB = 9;
const KEY_SHIFT = 16;
const KEY_WINDOWS = 91;
module.exports = React.createClass({
displayName: 'EditableText',
@ -66,9 +61,7 @@ module.exports = React.createClass({
},
componentWillReceiveProps: function(nextProps) {
if (nextProps.initialValue !== this.props.initialValue ||
nextProps.initialValue !== this.value
) {
if (nextProps.initialValue !== this.props.initialValue) {
this.value = nextProps.initialValue;
if (this.refs.editable_div) {
this.showPlaceholder(!this.value);
@ -139,7 +132,7 @@ module.exports = React.createClass({
this.showPlaceholder(false);
}
if (ev.key == "Enter") {
if (ev.key === "Enter") {
ev.stopPropagation();
ev.preventDefault();
}
@ -156,9 +149,9 @@ module.exports = React.createClass({
this.value = ev.target.textContent;
}
if (ev.key == "Enter") {
if (ev.key === "Enter") {
this.onFinish(ev);
} else if (ev.key == "Escape") {
} else if (ev.key === "Escape") {
this.cancelEdit();
}
@ -193,7 +186,7 @@ module.exports = React.createClass({
const submit = (ev.key === "Enter") || shouldSubmit;
this.setState({
phase: this.Phases.Display,
}, function() {
}, () => {
if (this.value !== this.props.initialValue) {
self.onValueChanged(submit);
}
@ -204,23 +197,35 @@ module.exports = React.createClass({
const sel = window.getSelection();
sel.removeAllRanges();
if (this.props.blurToCancel) {this.cancelEdit();} else {this.onFinish(ev, this.props.blurToSubmit);}
if (this.props.blurToCancel) {
this.cancelEdit();
} else {
this.onFinish(ev, this.props.blurToSubmit);
}
this.showPlaceholder(!this.value);
},
render: function() {
let editable_el;
const {className, editable, initialValue, label, labelClassName} = this.props;
let editableEl;
if (!this.props.editable || (this.state.phase == this.Phases.Display && (this.props.label || this.props.labelClassName) && !this.value)) {
if (!editable || (this.state.phase === this.Phases.Display && (label || labelClassName) && !this.value)) {
// show the label
editable_el = <div className={this.props.className + " " + this.props.labelClassName} onClick={this.onClickDiv}>{ this.props.label || this.props.initialValue }</div>;
editableEl = <div className={className + " " + labelClassName} onClick={this.onClickDiv}>
{ label || initialValue }
</div>;
} else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
editable_el = <div ref="editable_div" contentEditable="true" className={this.props.className}
onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur}></div>;
editableEl = <div ref="editable_div"
contentEditable={true}
className={className}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onFocus={this.onFocus}
onBlur={this.onBlur} />;
}
return editable_el;
return editableEl;
},
});

View file

@ -36,7 +36,7 @@ function getOrCreateContainer() {
}
// Greater than that of the ContextualMenu
const PE_Z_INDEX = 3000;
const PE_Z_INDEX = 5000;
/*
* Class of component that renders its children in a separate ReactDOM virtual tree

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -22,12 +23,13 @@ import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix';
import { getDisplayAliasForRoom } from '../../../Rooms';
import FlairStore from "../../../stores/FlairStore";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
// For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`)
const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+])[^\/]*)$/;
const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room|group)\/(([#!@+])[^\/]*)$/;
const Pill = React.createClass({
statics: {
@ -45,6 +47,7 @@ const Pill = React.createClass({
},
TYPE_USER_MENTION: 'TYPE_USER_MENTION',
TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION',
TYPE_GROUP_MENTION: 'TYPE_GROUP_MENTION',
TYPE_AT_ROOM_MENTION: 'TYPE_AT_ROOM_MENTION', // '@room' mention
},
@ -81,12 +84,14 @@ const Pill = React.createClass({
// The member related to the user pill
member: null,
// The group related to the group pill
group: null,
// The room related to the room pill
room: null,
};
},
componentWillReceiveProps(nextProps) {
async componentWillReceiveProps(nextProps) {
let regex = REGEX_MATRIXTO;
if (nextProps.inMessage) {
regex = REGEX_LOCAL_MATRIXTO;
@ -109,9 +114,11 @@ const Pill = React.createClass({
'@': Pill.TYPE_USER_MENTION,
'#': Pill.TYPE_ROOM_MENTION,
'!': Pill.TYPE_ROOM_MENTION,
'+': Pill.TYPE_GROUP_MENTION,
}[prefix];
let member;
let group;
let room;
switch (pillType) {
case Pill.TYPE_AT_ROOM_MENTION: {
@ -140,8 +147,21 @@ const Pill = React.createClass({
}
}
break;
case Pill.TYPE_GROUP_MENTION: {
const cli = MatrixClientPeg.get();
try {
group = await FlairStore.getGroupProfileCached(cli, resourceId);
} catch (e) { // if FlairStore failed, fall back to just groupId
group = {
groupId: resourceId,
avatarUrl: null,
name: null,
};
}
}
}
this.setState({resourceId, pillType, member, room});
this.setState({resourceId, pillType, member, group, room});
},
componentWillMount() {
@ -179,6 +199,7 @@ const Pill = React.createClass({
});
},
render: function() {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
@ -229,6 +250,20 @@ const Pill = React.createClass({
}
}
break;
case Pill.TYPE_GROUP_MENTION: {
if (this.state.group) {
const {avatarUrl, groupId, name} = this.state.group;
const cli = MatrixClientPeg.get();
linkText = groupId;
if (this.props.shouldShowPillAvatar) {
avatar = <BaseAvatar name={name || groupId} width={16} height={16}
url={avatarUrl ? cli.mxcUrlToHttp(avatarUrl, 16, 16) : null} />;
}
pillClass = 'mx_GroupPill';
}
}
break;
}
const classes = classNames(pillClass, {

View file

@ -160,7 +160,7 @@ export default class ReplyThread extends React.Component {
}
static makeThread(parentEv, onWidgetLoad, ref) {
if (!SettingsStore.isFeatureEnabled("feature_rich_quoting") || !ReplyThread.getParentEventId(parentEv)) {
if (!ReplyThread.getParentEventId(parentEv)) {
return <div />;
}
return <ReplyThread parentEv={parentEv} onWidgetLoad={onWidgetLoad} ref={ref} />;

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -103,14 +104,27 @@ export default React.createClass({
}
},
onContextButtonClick: function(e) {
e.preventDefault();
e.stopPropagation();
_openContextMenu: function(x, y, chevronOffset) {
// Hide the (...) immediately
this.setState({ hover: false });
const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu');
ContextualMenu.createMenu(TagTileContextMenu, {
chevronOffset: chevronOffset,
left: x,
top: y,
tag: this.props.tag,
onFinished: () => {
this.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
},
onContextButtonClick: function(e) {
e.preventDefault();
e.stopPropagation();
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
@ -119,17 +133,14 @@ export default React.createClass({
let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
const self = this;
ContextualMenu.createMenu(TagTileContextMenu, {
chevronOffset: chevronOffset,
left: x,
top: y,
tag: this.props.tag,
onFinished: function() {
self.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
this._openContextMenu(x, y, chevronOffset);
},
onContextMenu: function(e) {
e.preventDefault();
const chevronOffset = 12;
this._openContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset);
},
onMouseOver: function() {
@ -164,7 +175,7 @@ export default React.createClass({
<div className="mx_TagTile_context_button" onClick={this.onContextButtonClick}>
{ "\u00B7\u00B7\u00B7" }
</div> : <div />;
return <AccessibleButton className={className} onClick={this.onClick}>
return <AccessibleButton className={className} onClick={this.onClick} onContextMenu={this.onContextMenu}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar
name={name}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,28 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import sdk from '../../../index';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
export default React.createClass({
onUpdateClicked: function() {
const SetPasswordDialog = sdk.getComponent('dialogs.SetPasswordDialog');
Modal.createTrackedDialog('Set Password Dialog', 'Password Nag Bar', SetPasswordDialog, {
onFinished: (passwordChanged) => {
if (!passwordChanged) {
return;
}
// Notify SessionStore that the user's password was changed
dis.dispatch({
action: 'password_changed',
});
},
});
Modal.createTrackedDialog('Set Password Dialog', 'Password Nag Bar', SetPasswordDialog);
},
render: function() {

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,6 +21,9 @@ import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
import MatrixClientPeg from "../../../MatrixClientPeg";
import {createMenu} from "../../structures/ContextualMenu";
export default React.createClass({
displayName: 'GroupInviteTile',
@ -32,6 +36,15 @@ export default React.createClass({
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getInitialState: function() {
return ({
hover: false,
badgeHover: false,
menuDisplayed: false,
selected: this.props.group.groupId === null, // XXX: this needs linking to LoggedInView/GroupView state
});
},
onClick: function(e) {
dis.dispatch({
action: 'view_group',
@ -39,6 +52,69 @@ export default React.createClass({
});
},
onMouseEnter: function() {
const state = {hover: true};
// Only allow non-guests to access the context menu
if (!this.context.matrixClient.isGuest()) {
state.badgeHover = true;
}
this.setState(state);
},
onMouseLeave: function() {
this.setState({
badgeHover: false,
hover: false,
});
},
_showContextMenu: function(x, y, chevronOffset) {
const GroupInviteTileContextMenu = sdk.getComponent('context_menus.GroupInviteTileContextMenu');
createMenu(GroupInviteTileContextMenu, {
chevronOffset,
left: x,
top: y,
group: this.props.group,
onFinished: () => {
this.setState({ menuDisplayed: false });
},
});
this.setState({ menuDisplayed: true });
},
onContextMenu: function(e) {
// Prevent the RoomTile onClick event firing as well
e.preventDefault();
// Only allow non-guests to access the context menu
if (MatrixClientPeg.get().isGuest()) return;
const chevronOffset = 12;
this._showContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset);
},
onBadgeClicked: function(e) {
// Prevent the RoomTile onClick event firing as well
e.stopPropagation();
// Only allow non-guests to access the context menu
if (MatrixClientPeg.get().isGuest()) return;
// If the badge is clicked, then no longer show tooltip
if (this.props.collapsed) {
this.setState({ hover: false });
}
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = elementRect.right + window.pageXOffset + 3;
const chevronOffset = 12;
let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset);
y = y - (chevronOffset + 8); // where 8 is half the height of the chevron
this._showContextMenu(x, y, chevronOffset);
},
render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const EmojiText = sdk.getComponent('elements.EmojiText');
@ -49,19 +125,40 @@ export default React.createClass({
const av = <BaseAvatar name={groupName} width={24} height={24} url={httpAvatarUrl} />;
const label = <EmojiText
element="div"
title={this.props.group.groupId}
className="mx_RoomTile_name mx_RoomTile_badgeShown"
dir="auto"
>
const nameClasses = classNames('mx_RoomTile_name mx_RoomTile_invite mx_RoomTile_badgeShown', {
'mx_RoomTile_badgeShown': this.state.badgeHover || this.state.menuDisplayed,
});
const label = <EmojiText element="div" title={this.props.group.groupId} className={nameClasses} dir="auto">
{ groupName }
</EmojiText>;
const badge = <div className="mx_RoomSubList_badge mx_RoomSubList_badgeHighlight">!</div>;
const badgeEllipsis = this.state.badgeHover || this.state.menuDisplayed;
const badgeClasses = classNames('mx_RoomTile_badge mx_RoomTile_highlight', {
'mx_RoomTile_badgeButton': badgeEllipsis,
});
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
const badge = <div className={badgeClasses} onClick={this.onBadgeClicked}>{ badgeContent }</div>;
let tooltip;
if (this.props.collapsed && this.state.hover) {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
tooltip = <RoomTooltip className="mx_RoomTile_tooltip" label={groupName} dir="auto" />;
}
const classes = classNames('mx_RoomTile mx_RoomTile_highlight', {
'mx_RoomTile_menuDisplayed': this.state.menuDisplayed,
'mx_RoomTile_selected': this.state.selected,
});
return (
<AccessibleButton className="mx_RoomTile mx_RoomTile_highlight" onClick={this.onClick}>
<AccessibleButton className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
>
<div className="mx_RoomTile_avatar">
{ av }
</div>
@ -69,6 +166,7 @@ export default React.createClass({
{ label }
{ badge }
</div>
{ tooltip }
</AccessibleButton>
);
},

View file

@ -187,7 +187,7 @@ module.exports = React.createClass({
return (
<div className="mx_MemberInfo">
<GeminiScrollbarWrapper autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel"onClick={this._onCancel}>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this._onCancel}>
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" />
</AccessibleButton>
<div className="mx_MemberInfo_avatar">

View file

@ -69,7 +69,7 @@ export default React.createClass({
render() {
const GroupTile = sdk.getComponent('groups.GroupTile');
const input = <input type="checkbox"
onClick={this._onPublicityToggle}
onChange={this._onPublicityToggle}
checked={this.state.isGroupPublicised}
/>;
const labelText = !this.state.ready ? _t("Loading...") :

View file

@ -22,6 +22,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import FlairStore from '../../../stores/FlairStore';
function nop() {}
const GroupTile = React.createClass({
displayName: 'GroupTile',
@ -81,7 +82,7 @@ const GroupTile = React.createClass({
) : null;
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6156
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown}>
return <AccessibleButton className="mx_GroupTile" onMouseDown={this.onMouseDown} onClick={nop}>
<Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => (
<div ref={droppableProvided.innerRef}>

View file

@ -28,6 +28,7 @@ import SdkConfig from '../../../SdkConfig';
*/
class PasswordLogin extends React.Component {
static defaultProps = {
onError: function() {},
onUsernameChanged: function() {},
onPasswordChanged: function() {},
onPhoneCountryChanged: function() {},
@ -56,33 +57,64 @@ class PasswordLogin extends React.Component {
this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this);
this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this);
this.onPasswordChanged = this.onPasswordChanged.bind(this);
this.isLoginEmpty = this.isLoginEmpty.bind(this);
}
componentWillMount() {
this._passwordField = null;
this._loginField = null;
}
componentWillReceiveProps(nextProps) {
if (!this.props.loginIncorrect && nextProps.loginIncorrect) {
field_input_incorrect(this._passwordField);
field_input_incorrect(this.isLoginEmpty() ? this._loginField : this._passwordField);
}
}
onSubmitForm(ev) {
ev.preventDefault();
if (this.state.loginType === PasswordLogin.LOGIN_FIELD_PHONE) {
this.props.onSubmit(
'', // XXX: Synapse breaks if you send null here:
this.state.phoneCountry,
this.state.phoneNumber,
this.state.password,
);
let username = ''; // XXX: Synapse breaks if you send null here:
let phoneCountry = null;
let phoneNumber = null;
let error;
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
username = this.state.username;
if (!username) {
error = _t('The email field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_MXID:
username = this.state.username;
if (!username) {
error = _t('The user name field must not be blank.');
}
break;
case PasswordLogin.LOGIN_FIELD_PHONE:
phoneCountry = this.state.phoneCountry;
phoneNumber = this.state.phoneNumber;
if (!phoneNumber) {
error = _t('The phone number field must not be blank.');
}
break;
}
if (error) {
this.props.onError(error);
return;
}
if (!this.state.password) {
this.props.onError(_t('The password field must not be blank.'));
return;
}
this.props.onSubmit(
this.state.username,
null,
null,
username,
phoneCountry,
phoneNumber,
this.state.password,
);
}
@ -93,6 +125,7 @@ class PasswordLogin extends React.Component {
}
onLoginTypeChange(loginType) {
this.props.onError(null); // send a null error to clear any error messages
this.setState({
loginType: loginType,
username: "", // Reset because email and username use the same state
@ -126,8 +159,10 @@ class PasswordLogin extends React.Component {
switch (loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
classes.mx_Login_email = true;
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
key="email_input"
type="text"
name="username" // make it a little easier for browser's remember-password
@ -139,8 +174,10 @@ class PasswordLogin extends React.Component {
/>;
case PasswordLogin.LOGIN_FIELD_MXID:
classes.mx_Login_username = true;
classes.error = this.props.loginIncorrect && !this.state.username;
return <input
className={classNames(classes)}
ref={(e) => {this._loginField = e;}}
key="username_input"
type="text"
name="username" // make it a little easier for browser's remember-password
@ -153,14 +190,14 @@ class PasswordLogin extends React.Component {
autoFocus
disabled={disabled}
/>;
case PasswordLogin.LOGIN_FIELD_PHONE:
case PasswordLogin.LOGIN_FIELD_PHONE: {
const CountryDropdown = sdk.getComponent('views.login.CountryDropdown');
classes.mx_Login_phoneNumberField = true;
classes.mx_Login_field_has_prefix = true;
classes.error = this.props.loginIncorrect && !this.state.phoneNumber;
return <div className="mx_Login_phoneSection">
<CountryDropdown
className="mx_Login_phoneCountry mx_Login_field_prefix"
ref="phone_country"
onOptionChange={this.onPhoneCountryChanged}
value={this.state.phoneCountry}
isSmall={true}
@ -169,7 +206,7 @@ class PasswordLogin extends React.Component {
/>
<input
className={classNames(classes)}
ref="phoneNumber"
ref={(e) => {this._loginField = e;}}
key="phone_input"
type="text"
name="phoneNumber"
@ -180,6 +217,17 @@ class PasswordLogin extends React.Component {
disabled={disabled}
/>
</div>;
}
}
}
isLoginEmpty() {
switch (this.state.loginType) {
case PasswordLogin.LOGIN_FIELD_EMAIL:
case PasswordLogin.LOGIN_FIELD_MXID:
return !this.state.username;
case PasswordLogin.LOGIN_FIELD_PHONE:
return !this.state.phoneCountry || !this.state.phoneNumber;
}
}
@ -207,7 +255,7 @@ class PasswordLogin extends React.Component {
const pwFieldClass = classNames({
mx_Login_field: true,
mx_Login_field_disabled: matrixIdText === '',
error: this.props.loginIncorrect,
error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field
});
const Dropdown = sdk.getComponent('elements.Dropdown');
@ -258,6 +306,7 @@ PasswordLogin.LOGIN_FIELD_PHONE = "login_field_phone";
PasswordLogin.propTypes = {
onSubmit: PropTypes.func.isRequired, // fn(username, password)
onError: PropTypes.func,
onForgotPasswordClick: PropTypes.func, // fn()
initialUsername: PropTypes.string,
initialPhoneCountry: PropTypes.string,

View file

@ -327,6 +327,7 @@ module.exports = React.createClass({
// will have the correct name when the user tries to download it.
// We can't provide a Content-Disposition header like we would for HTTP.
download: fileName,
rel: "noopener",
target: "_blank",
textContent: _t("Download %(text)s", { text: text }),
}, "*");

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,25 +16,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import MFileBody from './MFileBody';
import ImageUtils from '../../../ImageUtils';
import Modal from '../../../Modal';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { decryptFile } from '../../../utils/DecryptFile';
import Promise from 'bluebird';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
export default class extends React.Component {
displayName: 'MImageBody'
export default class MImageBody extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
@ -43,23 +38,21 @@ export default class extends React.Component {
/* the maximum image height to use */
maxImageHeight: PropTypes.number,
}
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient),
}
};
constructor(props) {
super(props);
this.onAction = this.onAction.bind(this);
this.onImageError = this.onImageError.bind(this);
this.onImageLoad = this.onImageLoad.bind(this);
this.onImageEnter = this.onImageEnter.bind(this);
this.onImageLeave = this.onImageLeave.bind(this);
this.onClientSync = this.onClientSync.bind(this);
this.onClick = this.onClick.bind(this);
this.fixupHeight = this.fixupHeight.bind(this);
this._isGif = this._isGif.bind(this);
this.state = {
@ -68,6 +61,9 @@ export default class extends React.Component {
decryptedBlob: null,
error: null,
imgError: false,
imgLoaded: false,
loadedImageDimensions: null,
hover: false,
};
}
@ -91,7 +87,7 @@ export default class extends React.Component {
}
onClick(ev) {
if (ev.button == 0 && !ev.metaKey) {
if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault();
const content = this.props.mxEvent.getContent();
const httpUrl = this._getContentUrl();
@ -122,6 +118,8 @@ export default class extends React.Component {
}
onImageEnter(e) {
this.setState({ hover: true });
if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return;
}
@ -130,6 +128,8 @@ export default class extends React.Component {
}
onImageLeave(e) {
this.setState({ hover: false });
if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return;
}
@ -145,6 +145,16 @@ export default class extends React.Component {
onImageLoad() {
this.props.onWidgetLoad();
let loadedImageDimensions;
if (this.refs.image) {
const { naturalWidth, naturalHeight } = this.refs.image;
loadedImageDimensions = { naturalWidth, naturalHeight };
}
this.setState({ imgLoaded: true, loadedImageDimensions });
}
_getContentUrl() {
@ -164,9 +174,7 @@ export default class extends React.Component {
return this.state.decryptedThumbnailUrl;
}
return this.state.decryptedUrl;
} else if (content.info &&
content.info.mimetype == "image/svg+xml" &&
content.info.thumbnail_url) {
} else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) {
// special case to return client-generated thumbnails for SVGs, if any,
// given we deliberately don't thumbnail them serverside to prevent
// billion lol attacks and similar
@ -179,11 +187,10 @@ export default class extends React.Component {
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null);
if (content.info.thumbnail_file) {
if (content.info && content.info.thumbnail_file) {
thumbnailPromise = decryptFile(
content.info.thumbnail_file,
).then(function(blob) {
@ -210,7 +217,6 @@ export default class extends React.Component {
});
}).done();
}
this.fixupHeight();
this._afterComponentDidMount();
}
@ -221,7 +227,6 @@ export default class extends React.Component {
componentWillUnmount() {
this.unmounted = true;
dis.unregister(this.dispatcherRef);
this.context.matrixClient.removeListener('sync', this.onClientSync);
this._afterComponentWillUnmount();
@ -238,60 +243,113 @@ export default class extends React.Component {
_afterComponentWillUnmount() {
}
onAction(payload) {
if (payload.action === "timeline_resize") {
this.fixupHeight();
}
}
fixupHeight() {
if (!this.refs.image) {
console.warn(`Refusing to fix up height on ${this.displayName} with no image element`);
return;
}
const content = this.props.mxEvent.getContent();
const timelineWidth = this.refs.body.offsetWidth;
const maxHeight = this.props.maxImageHeight || 600; // let images take up as much width as they can so long
// as the height doesn't exceed 600px. The alternative here would be 600*timelineWidth/800; to scale them down
// to fit inside a 4:3 bounding box
// FIXME: this will break on clientside generated thumbnails (as per e2e rooms)
// which may well be much smaller than the 800x600 bounding box.
// FIXME: It will also break really badly for images with broken or missing thumbnails
// FIXME: Because we don't know what size of thumbnail the server's actually going to send
// us, we can't even really layout the page nicely for it. Instead we have to assume
// it'll target 800x600 and we'll downsize if needed to make things fit.
// console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
let thumbHeight = null;
if (content.info) {
thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight);
}
this.refs.image.style.height = thumbHeight + "px";
// console.log("Image height now", thumbHeight);
}
_messageContent(contentUrl, thumbUrl, content) {
let infoWidth;
let infoHeight;
if (content && content.info && content.info.w && content.info.h) {
infoWidth = content.info.w;
infoHeight = content.info.h;
} else {
// Whilst the image loads, display nothing.
//
// Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`.
//
// By doing this, the image "pops" into the timeline, but is still restricted
// by the same width and height logic below.
if (!this.state.loadedImageDimensions) {
return this.wrapImage(contentUrl,
<img style={{display: 'none'}} src={thumbUrl} ref="image"
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
/>,
);
}
infoWidth = this.state.loadedImageDimensions.naturalWidth;
infoHeight = this.state.loadedImageDimensions.naturalHeight;
}
// The maximum height of the thumbnail as it is rendered as an <img>
const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight);
// The maximum width of the thumbnail, as dictated by its natural
// maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight;
let img = null;
let placeholder = null;
// e2e image hasn't been decrypted yet
if (content.file !== undefined && this.state.decryptedUrl === null) {
placeholder = <img src="img/spinner.gif" alt={content.body} width="32" height="32" />;
} else if (!this.state.imgLoaded) {
// Deliberately, getSpinner is left unimplemented here, MStickerBody overides
placeholder = this.getPlaceholder();
}
const showPlaceholder = Boolean(placeholder);
if (thumbUrl && !this.state.imgError) {
// Restrict the width of the thumbnail here, otherwise it will fill the container
// which has the same width as the timeline
// mx_MImageBody_thumbnail resizes img to exactly container size
img = <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
style={{ maxWidth: maxWidth + "px" }}
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />;
}
const thumbnail = (
<a href={contentUrl} onClick={this.onClick}>
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
alt={content.body}
onError={this.onImageError}
onLoad={this.onImageLoad}
onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} />
</a>
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px" }} >
{ /* Calculate aspect ratio, using %padding will size _container correctly */ }
<div style={{ paddingBottom: (100 * infoHeight / infoWidth) + '%' }} />
{ showPlaceholder &&
<div className="mx_MImageBody_thumbnail" style={{
// Constrain width here so that spinner appears central to the loaded thumbnail
maxWidth: infoWidth + "px",
}}>
<div className="mx_MImageBody_thumbnail_spinner">
{ placeholder }
</div>
</div>
}
<div style={{display: !showPlaceholder ? undefined : 'none'}}>
{ img }
</div>
{ this.state.hover && this.getTooltip() }
</div>
);
return (
<span className="mx_MImageBody" ref="body">
{ thumbUrl && !this.state.imgError ? thumbnail : '' }
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span>
);
return this.wrapImage(contentUrl, thumbnail);
}
// Overidden by MStickerBody
wrapImage(contentUrl, children) {
return <a href={contentUrl} onClick={this.onClick}>
{children}
</a>;
}
// Overidden by MStickerBody
getPlaceholder() {
// MImageBody doesn't show a placeholder whilst the image loads, (but it could do)
return null;
}
// Overidden by MStickerBody
getTooltip() {
return null;
}
// Overidden by MStickerBody
getFileBody() {
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />;
}
render() {
@ -306,25 +364,6 @@ export default class extends React.Component {
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment
// The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner.
return (
<span className="mx_MImageBody" ref="body">
<div className="mx_MImageBody_thumbnail" ref="image" style={{
"display": "flex",
"alignItems": "center",
"width": "100%",
}}>
<img src="img/spinner.gif" alt={content.body} width="32" height="32" style={{
"margin": "auto",
}} />
</div>
</span>
);
}
const contentUrl = this._getContentUrl();
let thumbUrl;
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
@ -333,6 +372,12 @@ export default class extends React.Component {
thumbUrl = this._getThumbUrl();
}
return this._messageContent(contentUrl, thumbUrl, content);
const thumbnail = this._messageContent(contentUrl, thumbUrl, content);
const fileBody = this.getFileBody();
return <span className="mx_MImageBody" ref="body">
{ thumbnail }
{ fileBody }
</span>;
}
}

View file

@ -18,143 +18,39 @@ limitations under the License.
import MImageBody from './MImageBody';
import sdk from '../../../index';
import TintableSVG from '../elements/TintableSvg';
export default class MStickerBody extends MImageBody {
displayName: 'MStickerBody'
constructor(props) {
super(props);
this._onMouseEnter = this._onMouseEnter.bind(this);
this._onMouseLeave = this._onMouseLeave.bind(this);
this._onImageLoad = this._onImageLoad.bind(this);
}
_onMouseEnter() {
this.setState({showTooltip: true});
}
_onMouseLeave() {
this.setState({showTooltip: false});
}
_onImageLoad() {
this.setState({
placeholderClasses: 'mx_MStickerBody_placeholder_invisible',
});
const hidePlaceholderTimer = setTimeout(() => {
this.setState({
placeholderVisible: false,
imageClasses: 'mx_MStickerBody_image_visible',
});
}, 500);
this.setState({hidePlaceholderTimer});
if (this.props.onWidgetLoad) {
this.props.onWidgetLoad();
}
}
_afterComponentDidMount() {
if (this.refs.image.complete) {
// Image already loaded
this.setState({
placeholderVisible: false,
placeholderClasses: '.mx_MStickerBody_placeholder_invisible',
imageClasses: 'mx_MStickerBody_image_visible',
});
} else {
// Image not already loaded
this.setState({
placeholderVisible: true,
placeholderClasses: '',
imageClasses: '',
});
}
}
_afterComponentWillUnmount() {
if (this.state.hidePlaceholderTimer) {
clearTimeout(this.state.hidePlaceholderTimer);
this.setState({hidePlaceholderTimer: null});
}
}
_messageContent(contentUrl, thumbUrl, content) {
let tooltip;
const tooltipBody = (
this.props.mxEvent &&
this.props.mxEvent.getContent() &&
this.props.mxEvent.getContent().body) ?
this.props.mxEvent.getContent().body : null;
if (this.state.showTooltip && tooltipBody) {
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
tooltip = <RoomTooltip
className='mx_RoleButton_tooltip'
label={tooltipBody} />;
}
const gutterSize = 0;
let placeholderSize = 75;
let placeholderFixupHeight = '100px';
let placeholderTop = 0;
let placeholderLeft = 0;
if (content.info) {
placeholderTop = Math.floor((content.info.h/2) - (placeholderSize/2)) + 'px';
placeholderLeft = Math.floor((content.info.w/2) - (placeholderSize/2) + gutterSize) + 'px';
placeholderFixupHeight = content.info.h + 'px';
}
// The pixel size of sticker images is generally larger than their intended display
// size so they render at native reolution on HiDPI displays. We therefore need to
// explicity set the size so they render at the intended size.
// XXX: This will be clobberred when we run fixupHeight(), but we need to do it
// here otherwise the stickers are momentarily displayed at the pixel size.
const imageStyle = {
height: content.info.h,
// leave the browser the calculate the width automatically
};
placeholderSize = placeholderSize + 'px';
// Body 'ref' required by MImageBody
return (
<span className='mx_MStickerBody' ref='body'
style={{
height: placeholderFixupHeight,
}}>
<div className={'mx_MStickerBody_image_container'}>
{ this.state.placeholderVisible &&
<div
className={'mx_MStickerBody_placeholder ' + this.state.placeholderClasses}
style={{
top: placeholderTop,
left: placeholderLeft,
}}
>
<TintableSVG
src={'img/icons-show-stickers.svg'}
width={placeholderSize}
height={placeholderSize} />
</div> }
<img
className={'mx_MStickerBody_image ' + this.state.imageClasses}
src={contentUrl}
style={imageStyle}
ref='image'
alt={content.body}
onLoad={this._onImageLoad}
onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave}
/>
{ tooltip }
</div>
</span>
);
}
// Empty to prevent default behaviour of MImageBody
onClick() {
}
// MStickerBody doesn't need a wrapping `<a href=...>`, but it does need extra padding
// which is added by mx_MStickerBody_wrapper
wrapImage(contentUrl, children) {
return <div className="mx_MStickerBody_wrapper"> { children } </div>;
}
// Placeholder to show in place of the sticker image if
// img onLoad hasn't fired yet.
getPlaceholder() {
const TintableSVG = sdk.getComponent('elements.TintableSvg');
return <TintableSVG src="img/icons-show-stickers.svg" width="75" height="75" />;
}
// Tooltip to show on mouse over
getTooltip() {
const content = this.props.mxEvent && this.props.mxEvent.getContent();
if (!content || !content.body || !content.info || !content.info.w) return null;
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
return <div style={{left: content.info.w + 'px'}} className="mx_MStickerBody_tooltip">
<RoomTooltip label={content.body} />
</div>;
}
// Don't show "Download this_file.png ..."
getFileBody() {
return null;
}
}

View file

@ -147,12 +147,7 @@ module.exports = React.createClass({
// For now add an img tag with a spinner.
return (
<span className="mx_MVideoBody" ref="body">
<div className="mx_MImageBody_thumbnail" ref="image" style={{
"display": "flex",
"align-items": "center",
"justify-items": "center",
"width": "100%",
}}>
<div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner" ref="image">
<img src="img/spinner.gif" alt={content.body} width="16" height="16" />
</div>
</span>

View file

@ -62,17 +62,24 @@ module.exports = React.createClass({
'm.audio': sdk.getComponent('messages.MAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'),
};
const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'),
};
const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType();
const msgtype = content.msgtype;
let BodyType = UnknownBody;
if (msgtype && bodyTypes[msgtype]) {
BodyType = bodyTypes[msgtype];
} else if (this.props.mxEvent.getType() === 'm.sticker') {
BodyType = sdk.getComponent('messages.MStickerBody');
} else if (content.url) {
// Fallback to MFileBody if there's a content URL
BodyType = bodyTypes['m.file'];
if (!this.props.mxEvent.isRedacted()) {
// only resolve BodyType if event is not redacted
if (type && evTypes[type]) {
BodyType = evTypes[type];
} else if (msgtype && bodyTypes[msgtype]) {
BodyType = bodyTypes[msgtype];
} else if (content.url) {
// Fallback to MFileBody if there's a content URL
BodyType = bodyTypes['m.file'];
}
}
return <BodyType

View file

@ -72,14 +72,12 @@ export default React.createClass({
_updateRelatedGroups() {
if (this.unmounted) return;
const relatedGroupsEvent = this.context.matrixClient
.getRoom(this.props.mxEvent.getRoomId())
.currentState
.getStateEvents('m.room.related_groups', '');
const room = this.context.matrixClient.getRoom(this.props.mxEvent.getRoomId());
if (!room) return;
const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', '');
this.setState({
relatedGroups: relatedGroupsEvent ?
relatedGroupsEvent.getContent().groups || []
: [],
relatedGroups: relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [],
});
},

View file

@ -36,6 +36,7 @@ import * as ContextualMenu from '../../structures/ContextualMenu';
import SettingsStore from "../../../settings/SettingsStore";
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import ReplyThread from "../elements/ReplyThread";
import {host as matrixtoHost} from '../../../matrix-to';
linkifyMatrix(linkify);
@ -304,7 +305,7 @@ module.exports = React.createClass({
// never preview matrix.to links (if anything we should give a smart
// preview of the room/user they point to: nobody needs to be reminded
// what the matrix.to site looks like).
if (host == 'matrix.to') return false;
if (host === matrixtoHost) return false;
if (node.textContent.toLowerCase().trim().startsWith(host.toLowerCase())) {
// it's a "foo.pl" style link
@ -336,10 +337,21 @@ module.exports = React.createClass({
left: x,
top: y,
message: successful ? _t('Copied!') : _t('Failed to copy'),
});
e.target.onmouseout = close;
}, false);
e.target.onmouseleave = close;
};
p.appendChild(button);
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> overflows and is scrolled horizontally.
const div = document.createElement("div");
div.className = "mx_EventTile_pre_container";
// Insert containing div in place of <pre> block
p.parentNode.replaceChild(div, p);
// Append <pre> block and copy button to container
div.appendChild(p);
div.appendChild(button);
});
},
@ -422,8 +434,7 @@ module.exports = React.createClass({
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
const stripReply = SettingsStore.isFeatureEnabled("feature_rich_quoting") &&
ReplyThread.getParentEventId(mxEvent);
const stripReply = ReplyThread.getParentEventId(mxEvent);
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
disableBigEmoji: SettingsStore.getValue('TextualBody.disableBigEmoji'),
// Part of Replies fallback support

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Travis Ralston
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,6 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {MatrixClient} from "matrix-js-sdk";
const React = require('react');
import PropTypes from 'prop-types';
const sdk = require("../../../index");
@ -29,6 +31,10 @@ module.exports = React.createClass({
room: PropTypes.object,
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
},
saveSettings: function() {
const promises = [];
if (this.refs.urlPreviewsRoom) promises.push(this.refs.urlPreviewsRoom.save());
@ -39,42 +45,58 @@ module.exports = React.createClass({
render: function() {
const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
const roomId = this.props.room.roomId;
const isEncrypted = this.context.matrixClient.isRoomEncrypted(roomId);
let previewsForAccount = null;
if (SettingsStore.getValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled")) {
previewsForAccount = (
_t("You have <a>enabled</a> URL previews by default.", {}, { 'a': (sub)=><a href="#/settings">{ sub }</a> })
);
} else {
previewsForAccount = (
_t("You have <a>disabled</a> URL previews by default.", {}, { 'a': (sub)=><a href="#/settings">{ sub }</a> })
);
}
let previewsForRoom = null;
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, "room")) {
previewsForRoom = (
<label>
<SettingsFlag name="urlPreviewsEnabled"
level={SettingLevel.ROOM}
roomId={this.props.room.roomId}
isExplicit={true}
manualSave={true}
ref="urlPreviewsRoom" />
</label>
);
} else {
let str = _td("URL previews are enabled by default for participants in this room.");
if (!SettingsStore.getValueAt(SettingLevel.ROOM, "urlPreviewsEnabled", roomId, /*explicit=*/true)) {
str = _td("URL previews are disabled by default for participants in this room.");
if (!isEncrypted) {
// Only show account setting state and room state setting state in non-e2ee rooms where they apply
const accountEnabled = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled");
if (accountEnabled) {
previewsForAccount = (
_t("You have <a>enabled</a> URL previews by default.", {}, {
'a': (sub)=><a href="#/settings">{ sub }</a>,
})
);
} else if (accountEnabled) {
previewsForAccount = (
_t("You have <a>disabled</a> URL previews by default.", {}, {
'a': (sub)=><a href="#/settings">{ sub }</a>,
})
);
}
previewsForRoom = (<label>{ _t(str) }</label>);
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, "room")) {
previewsForRoom = (
<label>
<SettingsFlag name="urlPreviewsEnabled"
level={SettingLevel.ROOM}
roomId={roomId}
isExplicit={true}
manualSave={true}
ref="urlPreviewsRoom" />
</label>
);
} else {
let str = _td("URL previews are enabled by default for participants in this room.");
if (!SettingsStore.getValueAt(SettingLevel.ROOM, "urlPreviewsEnabled", roomId, /*explicit=*/true)) {
str = _td("URL previews are disabled by default for participants in this room.");
}
previewsForRoom = (<label>{ _t(str) }</label>);
}
} else {
previewsForAccount = (
_t("In encrypted rooms, like this one, URL previews are disabled by default to ensure that your " +
"homeserver (where the previews are generated) cannot gather information about links you see in " +
"this room.")
);
}
const previewsForRoomAccount = (
<SettingsFlag name="urlPreviewsEnabled"
const previewsForRoomAccount = ( // in an e2ee room we use a special key to enforce per-room opt-in
<SettingsFlag name={isEncrypted ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'}
level={SettingLevel.ROOM_ACCOUNT}
roomId={this.props.room.roomId}
roomId={roomId}
manualSave={true}
ref="urlPreviewsSelf"
/>
@ -83,8 +105,13 @@ module.exports = React.createClass({
return (
<div className="mx_RoomSettings_toggles">
<h3>{ _t("URL Previews") }</h3>
<label>{ previewsForAccount }</label>
<div>
{ _t('When someone puts a URL in their message, a URL preview can be shown to give more ' +
'information about that link such as the title, description, and an image from the website.') }
</div>
<div>
{ previewsForAccount }
</div>
{ previewsForRoom }
<label>{ previewsForRoomAccount }</label>
</div>

View file

@ -27,7 +27,7 @@ import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../WidgetUtils';
import WidgetUtils from '../../../utils/WidgetUtils';
import SettingsStore from "../../../settings/SettingsStore";
// The maximum number of widgets that can be added in a room
@ -94,15 +94,7 @@ module.exports = React.createClass({
const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) {
case 'appsDrawer':
// When opening the app drawer when there aren't any apps,
// auto-launch the integrations manager to skip the awkward
// click on "Add widget"
if (action.show) {
const apps = this._getApps();
if (apps.length === 0) {
this._launchManageIntegrations();
}
localStorage.removeItem(hideWidgetKey);
} else {
// Store hidden state of widget
@ -171,14 +163,7 @@ module.exports = React.createClass({
},
_getApps: function() {
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets');
if (!appsStateEvents) {
return [];
}
return appsStateEvents.filter((ev) => {
return ev.getContent().type && ev.getContent().url;
}).map((ev) => {
return WidgetUtils.getRoomWidgets(this.props.room).map((ev) => {
return this._initAppConfig(ev.getStateKey(), ev.getContent(), ev.sender);
});
},

View file

@ -34,6 +34,7 @@ const ContextualMenu = require('../../structures/ContextualMenu');
import dis from '../../../dispatcher';
import {makeEventPermalink} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore";
import {EventStatus} from 'matrix-js-sdk';
const ObjectUtils = require('../../../ObjectUtils');
@ -442,26 +443,27 @@ module.exports = withMatrixClient(React.createClass({
const ev = this.props.mxEvent;
const props = {onClick: this.onCryptoClicked};
// event could not be decrypted
if (ev.getContent().msgtype === 'm.bad.encrypted') {
return <E2ePadlockUndecryptable {...props} />;
} else if (ev.isEncrypted()) {
if (this.state.verified) {
return <E2ePadlockVerified {...props} />;
} else {
return <E2ePadlockUnverified {...props} />;
}
} else {
// XXX: if the event is being encrypted (ie eventSendStatus ===
// encrypting), it might be nice to show something other than the
// open padlock?
}
// if the event is not encrypted, but it's an e2e room, show the
// open padlock
const e2eEnabled = this.props.matrixClient.isRoomEncrypted(ev.getRoomId());
if (e2eEnabled) {
return <E2ePadlockUnencrypted {...props} />;
// event is encrypted, display padlock corresponding to whether or not it is verified
if (ev.isEncrypted()) {
return this.state.verified ? <E2ePadlockVerified {...props} /> : <E2ePadlockUnverified {...props} />;
}
if (this.props.matrixClient.isRoomEncrypted(ev.getRoomId())) {
// else if room is encrypted
// and event is being encrypted or is not_sent (Unknown Devices/Network Error)
if (ev.status === EventStatus.ENCRYPTING) {
return <E2ePadlockEncrypting {...props} />;
}
if (ev.status === EventStatus.NOT_SENT) {
return <E2ePadlockNotSent {...props} />;
}
// if the event is not encrypted, but it's an e2e room, show the open padlock
return <E2ePadlockUnencrypted {...props} />;
}
// no padlock needed
@ -490,7 +492,7 @@ module.exports = withMatrixClient(React.createClass({
}
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted;
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const classes = classNames({
@ -608,13 +610,14 @@ module.exports = withMatrixClient(React.createClass({
switch (this.props.tileShape) {
case 'notif': {
const EmojiText = sdk.getComponent('elements.EmojiText');
const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
return (
<div className={classes}>
<div className="mx_EventTile_roomName">
<a href={permalink} onClick={this.onPermalinkClicked}>
<EmojiText element="a" href={permalink} onClick={this.onPermalinkClicked}>
{ room ? room.name : '' }
</a>
</EmojiText>
</div>
<div className="mx_EventTile_senderDetails">
{ avatar }
@ -715,9 +718,15 @@ module.exports = withMatrixClient(React.createClass({
},
}));
// XXX this'll eventually be dynamic based on the fields once we have extensible event types
const messageTypes = ['m.room.message', 'm.sticker'];
function isMessageEvent(ev) {
return (messageTypes.includes(ev.getType()));
}
module.exports.haveTileForEvent = function(e) {
// Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && e.getType() !== 'm.room.message') return false;
if (e.isRedacted() && !isMessageEvent(e)) return false;
const handler = getHandlerTile(e);
if (handler === undefined) return false;
@ -736,6 +745,14 @@ function E2ePadlockUndecryptable(props) {
);
}
function E2ePadlockEncrypting(props) {
return <E2ePadlock alt={_t("Encrypting")} src="img/e2e-encrypting.svg" width="10" height="12" {...props} />;
}
function E2ePadlockNotSent(props) {
return <E2ePadlock alt={_t("Encrypted, not sent")} src="img/e2e-not_sent.svg" width="10" height="12" {...props} />;
}
function E2ePadlockVerified(props) {
return (
<E2ePadlock alt={_t("Encrypted by a verified device")}

View file

@ -652,6 +652,13 @@ module.exports = withMatrixClient(React.createClass({
);
},
onShareUserClick: function() {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room member dialog', '', ShareDialog, {
target: this.props.member,
});
},
_renderUserOptions: function() {
const cli = this.props.matrixClient;
const member = this.props.member;
@ -725,13 +732,18 @@ module.exports = withMatrixClient(React.createClass({
}
}
if (!ignoreButton && !readReceiptButton && !insertPillButton && !inviteUserButton) return null;
const shareUserButton = (
<AccessibleButton onClick={this.onShareUserClick} className="mx_MemberInfo_field">
{ _t('Share Link to User') }
</AccessibleButton>
);
return (
<div>
<h3>{ _t("User Options") }</h3>
<div className="mx_MemberInfo_buttons">
{ readReceiptButton }
{ shareUserButton }
{ insertPillButton }
{ ignoreButton }
{ inviteUserButton }
@ -922,7 +934,9 @@ module.exports = withMatrixClient(React.createClass({
return (
<div className="mx_MemberInfo">
<GeminiScrollbarWrapper autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}> <img src="img/cancel.svg" width="18" height="18" /></AccessibleButton>
<AccessibleButton className="mx_MemberInfo_cancel" onClick={this.onCancel}>
<img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" />
</AccessibleButton>
<div className="mx_MemberInfo_avatar">
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
</div>

View file

@ -159,54 +159,20 @@ export default class MessageComposer extends React.Component {
});
}
// _startCallApp(isAudioConf) {
// dis.dispatch({
// action: 'appsDrawer',
// show: true,
// });
// const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets', '');
// let appsStateEvent = {};
// if (appsStateEvents) {
// appsStateEvent = appsStateEvents.getContent();
// }
// if (!appsStateEvent.videoConf) {
// appsStateEvent.videoConf = {
// type: 'jitsi',
// // FIXME -- This should not be localhost
// url: 'http://localhost:8000/jitsi.html',
// data: {
// confId: this.props.room.roomId.replace(/[^A-Za-z0-9]/g, '_') + Date.now(),
// isAudioConf: isAudioConf,
// },
// };
// MatrixClientPeg.get().sendStateEvent(
// this.props.room.roomId,
// 'im.vector.modular.widgets',
// appsStateEvent,
// '',
// ).then(() => console.log('Sent state'), (e) => console.error(e));
// }
// }
onCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId,
});
// this._startCallApp(false);
}
onVoiceCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({
action: 'place_call',
type: "voice",
room_id: this.props.room.roomId,
});
// this._startCallApp(true);
}
onInputContentChanged(content: string, selection: {start: number, end: number}) {

View file

@ -28,7 +28,7 @@ import Promise from 'bluebird';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
import SlashCommands from '../../../SlashCommands';
import {processCommandInput} from '../../../SlashCommands';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard';
import Modal from '../../../Modal';
import sdk from '../../../index';
@ -45,8 +45,7 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore';
import {MATRIXTO_URL_PATTERN, MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
import {MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione';
@ -158,6 +157,7 @@ export default class MessageComposerInput extends React.Component {
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
this.onTextPasted = this.onTextPasted.bind(this);
this.focusComposer = this.focusComposer.bind(this);
const isRichtextEnabled = SettingsStore.getValue('MessageComposerInput.isRichTextEnabled');
@ -271,13 +271,12 @@ export default class MessageComposerInput extends React.Component {
}
onAction = (payload) => {
const editor = this.refs.editor;
let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
case 'reply_to_event':
case 'focus_composer':
editor.focus();
this.focusComposer();
break;
case 'insert_mention': {
// Pretend that we've autocompleted this user because keeping two code
@ -320,7 +319,7 @@ export default class MessageComposerInput extends React.Component {
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.moveSelectionToEnd(editorState);
this.onEditorContentChanged(editorState);
editor.focus();
this.focusComposer();
}
}
break;
@ -722,7 +721,7 @@ export default class MessageComposerInput extends React.Component {
// Some commands (/join) require pills to be replaced with their text content
const commandText = this.removeMDLinks(contentState, ['#']);
const cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
const cmd = processCommandInput(this.props.room.roomId, commandText);
if (cmd) {
if (!cmd.error) {
this.historyManager.save(contentState, this.state.isRichtextEnabled ? 'html' : 'markdown');
@ -1156,6 +1155,10 @@ export default class MessageComposerInput extends React.Component {
this.handleKeyCommand('toggle-mode');
};
focusComposer() {
this.refs.editor.focus();
}
render() {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
@ -1180,9 +1183,9 @@ export default class MessageComposerInput extends React.Component {
activeEditorState.getCurrentContent().getBlocksAsArray());
return (
<div className="mx_MessageComposer_input_wrapper">
<div className="mx_MessageComposer_input_wrapper" onClick={this.focusComposer}>
<div className="mx_MessageComposer_autocomplete_wrapper">
{ SettingsStore.isFeatureEnabled("feature_rich_quoting") && <ReplyPreview /> }
<ReplyPreview />
<Autocomplete
ref={(e) => this.autocomplete = e}
room={this.props.room}

View file

@ -149,6 +149,13 @@ module.exports = React.createClass({
dis.dispatch({ action: 'show_right_panel' });
},
onShareRoomClick: function(ev) {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share room dialog', '', ShareDialog, {
target: this.props.room,
});
},
_hasUnreadPins: function() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false;
@ -379,6 +386,14 @@ module.exports = React.createClass({
</AccessibleButton>;
}
let shareRoomButton;
if (this.props.inRoom) {
shareRoomButton =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShareRoomClick} title={_t('Share room')}>
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>;
}
let rightPanelButtons;
if (this.props.collapsedRhs) {
rightPanelButtons =
@ -400,6 +415,7 @@ module.exports = React.createClass({
<div className="mx_RoomHeader_rightRow">
{ settingsButton }
{ pinnedEventsButton }
{ shareRoomButton }
{ manageIntegsButton }
{ forgetButton }
{ searchButton }

Some files were not shown because too many files have changed in this diff Show more