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/AutocompleteProvider.js
src/autocomplete/Autocompleter.js src/autocomplete/Autocompleter.js
src/autocomplete/EmojiProvider.js
src/autocomplete/UserProvider.js src/autocomplete/UserProvider.js
src/CallHandler.js
src/component-index.js src/component-index.js
src/components/structures/BottomLeftMenu.js src/components/structures/BottomLeftMenu.js
src/components/structures/CompatibilityPage.js src/components/structures/CompatibilityPage.js
@ -13,27 +11,22 @@ src/components/structures/HomePage.js
src/components/structures/LeftPanel.js src/components/structures/LeftPanel.js
src/components/structures/LoggedInView.js src/components/structures/LoggedInView.js
src/components/structures/login/ForgotPassword.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/LoginBox.js
src/components/structures/MessagePanel.js src/components/structures/MessagePanel.js
src/components/structures/NotificationPanel.js src/components/structures/NotificationPanel.js
src/components/structures/RoomDirectory.js src/components/structures/RoomDirectory.js
src/components/structures/RoomStatusBar.js src/components/structures/RoomStatusBar.js
src/components/structures/RoomSubList.js
src/components/structures/RoomView.js src/components/structures/RoomView.js
src/components/structures/ScrollPanel.js src/components/structures/ScrollPanel.js
src/components/structures/SearchBox.js src/components/structures/SearchBox.js
src/components/structures/TimelinePanel.js src/components/structures/TimelinePanel.js
src/components/structures/UploadBar.js src/components/structures/UploadBar.js
src/components/structures/UserSettings.js
src/components/structures/ViewSource.js src/components/structures/ViewSource.js
src/components/views/avatars/BaseAvatar.js src/components/views/avatars/BaseAvatar.js
src/components/views/avatars/GroupAvatar.js
src/components/views/avatars/MemberAvatar.js src/components/views/avatars/MemberAvatar.js
src/components/views/create_room/RoomAlias.js src/components/views/create_room/RoomAlias.js
src/components/views/dialogs/BugReportDialog.js
src/components/views/dialogs/ChangelogDialog.js src/components/views/dialogs/ChangelogDialog.js
src/components/views/dialogs/ChatCreateOrReuseDialog.js
src/components/views/dialogs/DeactivateAccountDialog.js src/components/views/dialogs/DeactivateAccountDialog.js
src/components/views/dialogs/SetPasswordDialog.js src/components/views/dialogs/SetPasswordDialog.js
src/components/views/dialogs/UnknownDeviceDialog.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/AddressSelector.js
src/components/views/elements/DeviceVerifyButtons.js src/components/views/elements/DeviceVerifyButtons.js
src/components/views/elements/DirectorySearchBox.js src/components/views/elements/DirectorySearchBox.js
src/components/views/elements/EditableText.js
src/components/views/elements/ImageView.js src/components/views/elements/ImageView.js
src/components/views/elements/InlineSpinner.js src/components/views/elements/InlineSpinner.js
src/components/views/elements/MemberEventListSummary.js src/components/views/elements/MemberEventListSummary.js
src/components/views/elements/Spinner.js src/components/views/elements/Spinner.js
src/components/views/elements/TintableSvg.js src/components/views/elements/TintableSvg.js
src/components/views/elements/UserInfo.js
src/components/views/elements/UserSelector.js src/components/views/elements/UserSelector.js
src/components/views/globals/MatrixToolbar.js src/components/views/globals/MatrixToolbar.js
src/components/views/globals/NewVersionBar.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/Autocomplete.js
src/components/views/rooms/AuxPanel.js src/components/views/rooms/AuxPanel.js
src/components/views/rooms/EntityTile.js src/components/views/rooms/EntityTile.js
src/components/views/rooms/EventTile.js
src/components/views/rooms/LinkPreviewWidget.js src/components/views/rooms/LinkPreviewWidget.js
src/components/views/rooms/MemberDeviceInfo.js src/components/views/rooms/MemberDeviceInfo.js
src/components/views/rooms/MemberInfo.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/MemberTile.js
src/components/views/rooms/MessageComposer.js src/components/views/rooms/MessageComposer.js
src/components/views/rooms/MessageComposerInput.js src/components/views/rooms/MessageComposerInput.js
src/components/views/rooms/PinnedEventTile.js
src/components/views/rooms/RoomDropTarget.js src/components/views/rooms/RoomDropTarget.js
src/components/views/rooms/RoomList.js src/components/views/rooms/RoomList.js
src/components/views/rooms/RoomPreviewBar.js src/components/views/rooms/RoomPreviewBar.js
src/components/views/rooms/RoomSettings.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/SearchableEntityList.js
src/components/views/rooms/SearchBar.js src/components/views/rooms/SearchBar.js
src/components/views/rooms/SearchResultTile.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/rooms/UserTile.js
src/components/views/settings/AddPhoneNumber.js src/components/views/settings/AddPhoneNumber.js
src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangeAvatar.js
src/components/views/settings/ChangeDisplayName.js
src/components/views/settings/ChangePassword.js src/components/views/settings/ChangePassword.js
src/components/views/settings/DevicesPanel.js src/components/views/settings/DevicesPanel.js
src/components/views/settings/IntegrationsManager.js src/components/views/settings/IntegrationsManager.js
src/components/views/settings/Notifications.js src/components/views/settings/Notifications.js
src/ContentMessages.js src/ContentMessages.js
src/GroupAddressPicker.js
src/HtmlUtils.js src/HtmlUtils.js
src/ImageUtils.js src/ImageUtils.js
src/languageHandler.js src/languageHandler.js
@ -135,6 +126,7 @@ test/components/structures/TimelinePanel-test.js
test/components/views/dialogs/InteractiveAuthDialog-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js
test/components/views/login/RegistrationForm-test.js test/components/views/login/RegistrationForm-test.js
test/components/views/rooms/MessageComposerInput-test.js test/components/views/rooms/MessageComposerInput-test.js
test/components/views/rooms/RoomSettings-test.js
test/mock-clock.js test/mock-clock.js
test/notifications/ContentRules-test.js test/notifications/ContentRules-test.js
test/notifications/PushRuleVectorState-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) 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) [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", "name": "matrix-react-sdk",
"version": "0.12.7", "version": "0.12.8",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -73,13 +73,14 @@
"glob": "^5.0.14", "glob": "^5.0.14",
"highlight.js": "^9.0.0", "highlight.js": "^9.0.0",
"isomorphic-fetch": "^2.2.1", "isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3", "linkifyjs": "^2.1.6",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"lolex": "2.3.2", "lolex": "2.3.2",
"matrix-js-sdk": "0.10.4", "matrix-js-sdk": "0.10.5",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"pako": "^1.0.5", "pako": "^1.0.5",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"qrcode-react": "^0.1.16",
"querystring": "^0.2.0", "querystring": "^0.2.0",
"react": "^15.6.0", "react": "^15.6.0",
"react-addons-css-transition-group": "15.3.2", "react-addons-css-transition-group": "15.3.2",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,6 +23,10 @@ limitations under the License.
padding-bottom: 12px; padding-bottom: 12px;
} }
.mx_CreateRoomDialog_input_container {
padding-right: 20px;
}
.mx_CreateRoomDialog_input { .mx_CreateRoomDialog_input {
font-size: 15px; font-size: 15px;
border-radius: 3px; border-radius: 3px;
@ -30,4 +34,5 @@ limitations under the License.
padding: 9px; padding: 9px;
color: $primary-fg-color; color: $primary-fg-color;
background-color: $primary-bg-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_UserPill,
.mx_RoomPill, .mx_RoomPill,
.mx_GroupPill,
.mx_AtRoomPill { .mx_AtRoomPill {
border-radius: 16px; border-radius: 16px;
display: inline-block; display: inline-block;
@ -13,7 +14,8 @@
} }
.mx_EventTile_body .mx_UserPill, .mx_EventTile_body .mx_UserPill,
.mx_EventTile_body .mx_RoomPill { .mx_EventTile_body .mx_RoomPill,
.mx_EventTile_body .mx_GroupPill {
cursor: pointer; cursor: pointer;
} }
@ -35,14 +37,25 @@
/* More specific to override `.markdown-body a` color */ /* More specific to override `.markdown-body a` color */
.mx_EventTile_content .markdown-body a.mx_RoomPill, .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; color: $accent-fg-color;
background-color: $rte-room-pill-color; background-color: $rte-room-pill-color;
padding-right: 5px; 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_UserPill .mx_BaseAvatar,
.mx_RoomPill .mx_BaseAvatar, .mx_RoomPill .mx_BaseAvatar,
.mx_GroupPill .mx_BaseAvatar,
.mx_AtRoomPill .mx_BaseAvatar { .mx_AtRoomPill .mx_BaseAvatar {
position: relative; position: relative;
left: -3px; left: -3px;

View file

@ -20,5 +20,29 @@ limitations under the License.
} }
.mx_MImageBody_thumbnail { .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. limitations under the License.
*/ */
.mx_MStickerBody { .mx_MStickerBody_wrapper {
display: block; padding: 20px 0px;
margin-right: 34px;
min-height: 110px;
padding: 20px 0;
} }
.mx_MStickerBody_image_container { .mx_MStickerBody_tooltip {
display: inline-block; position: absolute;
position: relative; top: 50%;
}
.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;
} }

View file

@ -17,8 +17,3 @@ limitations under the License.
.mx_MTextBody { .mx_MTextBody {
white-space: pre-wrap; 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 { .mx_EventTile_content .markdown-body pre {
overflow-x: overlay; overflow-x: overlay;
overflow-y: visible; overflow-y: visible;
max-height: 30vh;
} }
.mx_EventTile_content .markdown-body code { .mx_EventTile_content .markdown-body code {
@ -399,6 +400,12 @@ limitations under the License.
color: #333; 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 { .mx_EventTile_copyButton {
position: absolute; position: absolute;
display: inline-block; display: inline-block;
@ -412,7 +419,6 @@ limitations under the License.
} }
.mx_EventTile_body pre { .mx_EventTile_body pre {
position: relative;
border: 1px solid transparent; 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 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; visibility: visible;
} }

View file

@ -70,6 +70,7 @@ limitations under the License.
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
cursor: text;
} }
.mx_MessageComposer_input { .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-bg-color: #e9e9e9;
$rte-code-bg-color: rgba(0, 0, 0, 0.04); $rte-code-bg-color: rgba(0, 0, 0, 0.04);
$rte-room-pill-color: #aaa; $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 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() { 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); return origin + pathname + getRedactedHash(hash);
} }
@ -191,9 +199,9 @@ class Analytics {
this._paq.push(['trackPageView']); this._paq.push(['trackPageView']);
} }
trackEvent(category, action, name) { trackEvent(category, action, name, value) {
if (this.disabled) return; if (this.disabled) return;
this._paq.push(['trackEvent', category, action, name]); this._paq.push(['trackEvent', category, action, name, value]);
} }
logout() { logout() {

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 Matrix from 'matrix-js-sdk';
import dis from './dispatcher'; import dis from './dispatcher';
import { showUnknownDeviceDialogForCalls } from './cryptodevices'; import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import SettingsStore from "./settings/SettingsStore";
import WidgetUtils from './utils/WidgetUtils';
global.mxCalls = { global.mxCalls = {
//room_id: MatrixCall //room_id: MatrixCall
@ -123,7 +125,7 @@ function _setCallListeners(call) {
description: _t( description: _t(
"There are unknown devices in this room: "+ "There are unknown devices in this room: "+
"if you proceed without verifying them, it will be "+ "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'), button: _t('Review Devices'),
onFinished: function(confirmed) { onFinished: function(confirmed) {
@ -246,66 +248,58 @@ function _onAction(payload) {
switch (payload.action) { switch (payload.action) {
case 'place_call': case 'place_call':
if (module.exports.getAnyActiveCall()) { {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); if (module.exports.getAnyActiveCall()) {
Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
title: _t('Existing Call'), Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
description: _t('You are already in a call.'), title: _t('Existing Call'),
}); description: _t('You are already in a call.'),
return; // don't allow >1 call to be placed. });
} return; // don't allow >1 call to be placed.
}
// if the runtime env doesn't do VoIP, whine. // if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'), title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'), description: _t('You cannot place VoIP calls in this browser.'),
}); });
return; return;
} }
var room = MatrixClientPeg.get().getRoom(payload.room_id); const room = MatrixClientPeg.get().getRoom(payload.room_id);
if (!room) { if (!room) {
console.error("Room %s does not exist.", payload.room_id); console.error("Room %s does not exist.", payload.room_id);
return; return;
} }
var members = room.getJoinedMembers(); const members = room.getJoinedMembers();
if (members.length <= 1) { if (members.length <= 1) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
description: _t('You cannot place a call with yourself.'), description: _t('You cannot place a call with yourself.'),
}); });
return; return;
} else if (members.length === 2) { } else if (members.length === 2) {
console.log("Place %s call in %s", payload.type, payload.room_id); console.log("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
placeCall(call); placeCall(call);
} else { // > 2 } else { // > 2
dis.dispatch({ dis.dispatch({
action: "place_conference_call", action: "place_conference_call",
room_id: payload.room_id, room_id: payload.room_id,
type: payload.type, type: payload.type,
remote_element: payload.remote_element, remote_element: payload.remote_element,
local_element: payload.local_element, local_element: payload.local_element,
}); });
}
} }
break; break;
case 'place_conference_call': case 'place_conference_call':
console.log("Place conference call in %s", payload.room_id); console.log("Place conference call in %s", payload.room_id);
if (!ConferenceHandler) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) {
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)) {
// Conference calls are implemented by sending the media to central // Conference calls are implemented by sending the media to central
// server which combines the audio from all the participants together // server which combines the audio from all the participants together
// into a single stream. This is incompatible with end-to-end encryption // 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, { Modal.createTrackedDialog('Call Handler', 'Conference calls unsupported e2e', ErrorDialog, {
description: _t('Conference calls are not supported in encrypted rooms'), description: _t('Conference calls are not supported in encrypted rooms'),
}); });
return;
}
if (SettingsStore.isFeatureEnabled('feature_jitsi')) {
_startCallApp(payload.room_id, payload.type);
} else { } else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); if (!ConferenceHandler) {
Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
title: _t('Warning!'), Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, {
description: _t('Conference calling is in development and may not be reliable.'), description: _t('Conference calls are not supported in this client'),
onFinished: (confirm)=>{ });
if (confirm) { } else if (!MatrixClientPeg.get().supportsVoip()) {
ConferenceHandler.createNewMatrixCall( const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
MatrixClientPeg.get(), payload.room_id, Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
).done(function(call) { title: _t('VoIP is unsupported'),
placeCall(call); description: _t('You cannot place VoIP calls in this browser.'),
}, function(err) { });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); } else {
console.error("Conference call failed: " + err); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Handler', 'Failed to set up conference call', ErrorDialog, { Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, {
title: _t('Failed to set up conference call'), title: _t('Warning!'),
description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''), 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; break;
case 'incoming_call': case 'incoming_call':
if (module.exports.getAnyActiveCall()) { {
// ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. if (module.exports.getAnyActiveCall()) {
// we avoid rejecting with "busy" in case the user wants to answer it on a different device. // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
// in future we could signal a "local busy" as a warning to the caller. // we avoid rejecting with "busy" in case the user wants to answer it on a different device.
// see https://github.com/vector-im/vector-web/issues/1964 // in future we could signal a "local busy" as a warning to the caller.
return; // see https://github.com/vector-im/vector-web/issues/1964
} return;
}
// if the runtime env doesn't do VoIP, stop here. // if the runtime env doesn't do VoIP, stop here.
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
return; return;
} }
var call = payload.call; const call = payload.call;
_setCallListeners(call); _setCallListeners(call);
_setCallState(call, call.roomId, "ringing"); _setCallState(call, call.roomId, "ringing");
}
break; break;
case 'hangup': case 'hangup':
if (!calls[payload.room_id]) { if (!calls[payload.room_id]) {
@ -378,6 +400,71 @@ function _onAction(payload) {
break; 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 // FIXME: Nasty way of making sure we only register
// with the dispatcher once // with the dispatcher once
if (!global.mxCallHandler) { if (!global.mxCallHandler) {

View file

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

View file

@ -243,6 +243,7 @@ function uploadFile(matrixClient, roomId, file, progressHandler) {
const blob = new Blob([encryptResult.data]); const blob = new Blob([encryptResult.data]);
return matrixClient.uploadContent(blob, { return matrixClient.uploadContent(blob, {
progressHandler: progressHandler, progressHandler: progressHandler,
includeFilename: false,
}).then(function(url) { }).then(function(url) {
// If the attachment is encrypted then bundle the URL along // If the attachment is encrypted then bundle the URL along
// with the information needed to decrypt the attachment and // 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); m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN);
if (m) { if (m) {
const entity = m[1]; const entity = m[1];
if (entity[0] === '@') { switch (entity[0]) {
attribs.href = '#/user/' + entity; case '@':
} else if (entity[0] === '#' || entity[0] === '!') { attribs.href = '#/user/' + entity;
attribs.href = '#/room/' + entity; break;
case '+':
attribs.href = '#/group/' + entity;
break;
case '#':
case '!':
attribs.href = '#/room/' + entity;
break;
} }
delete attribs.target; delete attribs.target;
} }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -231,11 +232,12 @@ Example:
} }
*/ */
const SdkConfig = require('./SdkConfig'); import SdkConfig from './SdkConfig';
const MatrixClientPeg = require("./MatrixClientPeg"); import MatrixClientPeg from './MatrixClientPeg';
const MatrixEvent = require("matrix-js-sdk").MatrixEvent; import { MatrixEvent } from 'matrix-js-sdk';
const dis = require("./dispatcher"); import dis from './dispatcher';
const Widgets = require('./utils/widgets'); import WidgetUtils from './utils/WidgetUtils';
import RoomViewStore from './stores/RoomViewStore';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
function sendResponse(event, res) { 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) { function setWidget(event, roomId) {
const widgetId = event.data.widget_id; const widgetId = event.data.widget_id;
const widgetType = event.data.type; const widgetType = event.data.type;
@ -339,12 +296,6 @@ function setWidget(event, roomId) {
const widgetData = event.data.data; // optional const widgetData = event.data.data; // optional
const userWidget = event.data.userWidget; 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 // both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) { if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields.")); 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) { if (userWidget) {
const client = MatrixClientPeg.get(); WidgetUtils.setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
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(() => {
sendResponse(event, { sendResponse(event, {
success: true, success: true,
}); });
@ -419,15 +336,7 @@ function setWidget(event, roomId) {
if (!roomId) { if (!roomId) {
sendError(event, _t('Missing roomId.'), null); sendError(event, _t('Missing roomId.'), null);
} }
WidgetUtils.setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
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.
sendResponse(event, { sendResponse(event, {
success: true, success: true,
}); });
@ -451,21 +360,13 @@ function getWidgets(event, roomId) {
sendError(event, _t('This room is not recognised.')); sendError(event, _t('This room is not recognised.'));
return; return;
} }
// TODO - Room widgets need to be moved to 'm.widget' state events // XXX: This gets the raw event object (I think because we can't
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing // send the MatrixEvent over postMessage?)
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); widgetStateEvents = WidgetUtils.getRoomWidgets(room).map((ev) => ev.event);
// 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
}
});
}
} }
// Add user widgets (not linked to a specific room) // Add user widgets (not linked to a specific room)
const userWidgets = Widgets.getUserWidgetsArray(); const userWidgets = WidgetUtils.getUserWidgetsArray();
widgetStateEvents = widgetStateEvents.concat(userWidgets); widgetStateEvents = widgetStateEvents.concat(userWidgets);
sendResponse(event, widgetStateEvents); sendResponse(event, widgetStateEvents);
@ -637,19 +538,6 @@ function returnStateEvent(event, roomId, eventType, stateKey) {
sendResponse(event, stateEvent.getContent()); 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) { const onMessage = function(event) {
if (!event.origin) { // stupid chrome if (!event.origin) { // stupid chrome
event.origin = event.originalEvent.origin; event.origin = event.originalEvent.origin;
@ -700,80 +588,63 @@ const onMessage = function(event) {
return; return;
} }
} }
let promise = Promise.resolve(currentRoomId);
if (!currentRoomId) { if (roomId !== RoomViewStore.getRoomId()) {
if (!currentRoomAlias) { sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId}));
sendError(event, _t('Must be viewing a room')); return;
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;
});
} }
promise.then((viewingRoomId) => { // Get and set room-based widgets
if (roomId !== viewingRoomId) { if (event.data.action === "get_widgets") {
sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId})); getWidgets(event, roomId);
return; return;
} } else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
}
// Get and set room-based widgets // These APIs don't require userId
if (event.data.action === "get_widgets") { if (event.data.action === "join_rules_state") {
getWidgets(event, roomId); getJoinRules(event, roomId);
return; return;
} else if (event.data.action === "set_widget") { } else if (event.data.action === "set_plumbing_state") {
setWidget(event, roomId); setPlumbingState(event, roomId, event.data.status);
return; 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 (!userId) {
if (event.data.action === "join_rules_state") { sendError(event, _t('Missing user_id in request'));
getJoinRules(event, roomId); return;
return; }
} else if (event.data.action === "set_plumbing_state") { switch (event.data.action) {
setPlumbingState(event, roomId, event.data.status); case "membership_state":
return; getMembershipState(event, roomId, userId);
} else if (event.data.action === "get_membership_count") { break;
getMembershipCount(event, roomId); case "invite":
return; inviteUser(event, roomId, userId);
} else if (event.data.action === "get_room_enc_state") { break;
getRoomEncState(event, roomId); case "bot_options":
return; botOptions(event, roomId, userId);
} else if (event.data.action === "can_send_event") { break;
canSendEvent(event, roomId); case "set_bot_options":
return; setBotOptions(event, roomId, userId);
} break;
case "set_bot_power":
if (!userId) { setBotPower(event, roomId, userId, event.data.level);
sendError(event, _t('Missing user_id in request')); break;
return; default:
} console.warn("Unhandled postMessage event with action '" + event.data.action +"'");
switch (event.data.action) { break;
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') + '.');
});
}; };
let listenerCount = 0; let listenerCount = 0;

View file

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

View file

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

View file

@ -2,6 +2,7 @@
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 React from 'react';
import { _t, _td } from '../languageHandler'; import {_t} from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import FuzzyMatcher from './FuzzyMatcher'; import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
import type {Completion, SelectionRange} from "./Autocompleter";
import {CommandMap} from '../SlashCommands';
// TODO merge this with the factory mechanics of SlashCommands? const COMMANDS = Object.values(CommandMap);
// 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 COMMAND_RE = /(^\/\w*)/g; const COMMAND_RE = /(^\/\w*)(?: .*)?/g;
export default class CommandProvider extends AutocompleteProvider { export default class CommandProvider extends AutocompleteProvider {
constructor() { constructor() {
@ -123,23 +37,36 @@ export default class CommandProvider extends AutocompleteProvider {
}); });
} }
async getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array<Completion> {
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (!command) return [];
completions = this.matcher.match(command[0]).map((result) => {
return { let matches = [];
completion: result.command + ' ', if (command[0] !== command[1]) {
component: (<TextualCompletion // The input looks like a command with arguments, perform exact match
title={result.command} const name = command[1].substr(1); // strip leading `/`
subtitle={result.args} if (CommandMap[name]) {
description={_t(result.description)} matches = [CommandMap[name]];
/>), }
range, } 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() { 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 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 'whatwg-fetch';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
import type {SelectionRange} from "./Autocompleter";
const DDG_REGEX = /\/ddg\s+(.+)$/g; const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector'; const REFERRER = 'vector';
@ -36,7 +37,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; + `&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); const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) { if (!query || !command) {
return []; return [];

View file

@ -1,7 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 React from 'react';
import { _t } from '../languageHandler'; import { _t } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import {emojioneList, shortnameToImage, shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione'; import {shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione';
import FuzzyMatcher from './FuzzyMatcher'; import FuzzyMatcher from './FuzzyMatcher';
import sdk from '../index'; import sdk from '../index';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter'; import type {Completion, SelectionRange} from './Autocompleter';
import _uniq from 'lodash/uniq'; import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import SettingsStore from "../settings/SettingsStore"; 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")) { if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them 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 MatrixClientPeg from '../MatrixClientPeg';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import sdk from '../index'; import sdk from '../index';
import type {Completion, SelectionRange} from "./Autocompleter";
const AT_ROOM_REGEX = /@\S*/g; const AT_ROOM_REGEX = /@\S*/g;
@ -29,7 +30,7 @@ export default class NotifProvider extends AutocompleteProvider {
this.room = room; 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 RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();

View file

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

View file

@ -1,7 +1,8 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 sdk from '../index';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../matrix-to"; 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) { function score(query, space) {
const index = space.indexOf(query); 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'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
// Disable autocompletions when composing commands because of various issues // Disable autocompletions when composing commands because of various issues

View file

@ -2,7 +2,8 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 {PillCompletion} from './Components';
import sdk from '../index'; import sdk from '../index';
import FuzzyMatcher from './FuzzyMatcher'; import FuzzyMatcher from './FuzzyMatcher';
import _pull from 'lodash/pull';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg'; 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 {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 { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null; users: Array<RoomMember> = null;
room: Room = null; room: Room = null;
constructor(room) { constructor(room: Room) {
super(USER_REGEX, { super(USER_REGEX, {
keys: ['name'], keys: ['name'],
}); });
@ -44,7 +45,7 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
shouldMatchPrefix: true, shouldMatchPrefix: true,
shouldMatchWordsOnly: false shouldMatchWordsOnly: false,
}); });
this._onRoomTimelineBound = this._onRoomTimeline.bind(this); 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 (!room) return;
if (removed) return; if (removed) return;
if (room.roomId !== this.room.roomId) return; if (room.roomId !== this.room.roomId) return;
@ -77,7 +78,7 @@ export default class UserProvider extends AutocompleteProvider {
this.onUserSpoke(ev.sender); this.onUserSpoke(ev.sender);
} }
_onRoomStateMember(ev, state, member) { _onRoomStateMember(ev: MatrixEvent, state: RoomState, member: RoomMember) {
// ignore members in other rooms // ignore members in other rooms
if (member.roomId !== this.room.roomId) { if (member.roomId !== this.room.roomId) {
return; return;
@ -87,7 +88,7 @@ export default class UserProvider extends AutocompleteProvider {
this.users = null; 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'); const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues // Disable autocompletions when composing commands because of various issues
@ -128,7 +129,7 @@ export default class UserProvider extends AutocompleteProvider {
return completions; return completions;
} }
getName() { getName(): string {
return '👥 ' + _t('Users'); return '👥 ' + _t('Users');
} }
@ -141,13 +142,9 @@ export default class UserProvider extends AutocompleteProvider {
} }
const currentUserId = MatrixClientPeg.get().credentials.userId; const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = this.room.getJoinedMembers().filter((member) => { this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
if (member.userId !== currentUserId) return true;
});
this.users = _sortBy(this.users, (member) => this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
1E20 - lastSpoken[member.userId] || 1E20,
);
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,12 +16,10 @@ limitations under the License.
*/ */
'use strict'; import React from 'react';
import ReactDOM from 'react-dom';
const classNames = require('classnames');
const React = require('react');
const ReactDOM = require('react-dom');
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames';
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // 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 // If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it. // menu that when clicked will close it.
hasBackground: PropTypes.bool, 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() { render() {
@ -83,6 +130,9 @@ export default class ContextualMenu extends React.Component {
chevronFace = 'right'; chevronFace = 'right';
} }
const contextMenuRect = this.state.contextMenuRect || null;
const padding = 10;
const chevronOffset = {}; const chevronOffset = {};
if (props.chevronFace) { if (props.chevronFace) {
chevronFace = props.chevronFace; chevronFace = props.chevronFace;
@ -90,7 +140,19 @@ export default class ContextualMenu extends React.Component {
if (chevronFace === 'top' || chevronFace === 'bottom') { if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset; chevronOffset.left = props.chevronOffset;
} else { } 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 // 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 className = 'mx_ContextualMenu_wrapper';
const menuClasses = classNames({ const menuClasses = classNames({
@ -154,17 +216,17 @@ export default class ContextualMenu extends React.Component {
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished // FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click! // property set here so you can't close the menu from a button click!
return <div className={className} style={position}> return <div className={className} style={position}>
<div className={menuClasses} style={menuStyle}> <div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect}>
{ chevron } { chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} /> <ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div> </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> <style>{ chevronCSS }</style>
</div>; </div>;
} }
} }
export function createMenu(ElementClass, props) { export function createMenu(ElementClass, props, hasBackground=true) {
const closeMenu = function(...args) { const closeMenu = function(...args) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer()); ReactDOM.unmountComponentAtNode(getOrCreateContainer());
@ -175,8 +237,8 @@ export function createMenu(ElementClass, props) {
// We only reference closeMenu once per call to createMenu // We only reference closeMenu once per call to createMenu
const menu = <ContextualMenu const menu = <ContextualMenu
hasBackground={hasBackground}
{...props} {...props}
hasBackground={true}
elementClass={ElementClass} elementClass={ElementClass}
closeMenu={closeMenu} // eslint-disable-line react/jsx-no-bind closeMenu={closeMenu} // eslint-disable-line react/jsx-no-bind
windowResize={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": { "room": {
"timeline": { "timeline": {
"contains_url": true, "contains_url": true,
"not_types": [ "types": [
"m.sticker", "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() { _onCancelClick: function() {
this._closeSettings(); this._closeSettings();
}, },
@ -1052,7 +1059,7 @@ export default React.createClass({
<input type="radio" <input type="radio"
value={GROUP_JOINPOLICY_INVITE} value={GROUP_JOINPOLICY_INVITE}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE} checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE}
onClick={this._onJoinableChange} onChange={this._onJoinableChange}
/> />
<div className="mx_GroupView_label_text"> <div className="mx_GroupView_label_text">
{ _t('Only people who have been invited') } { _t('Only people who have been invited') }
@ -1064,7 +1071,7 @@ export default React.createClass({
<input type="radio" <input type="radio"
value={GROUP_JOINPOLICY_OPEN} value={GROUP_JOINPOLICY_OPEN}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN} checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN}
onClick={this._onJoinableChange} onChange={this._onJoinableChange}
/> />
<div className="mx_GroupView_label_text"> <div className="mx_GroupView_label_text">
{ _t('Everyone') } { _t('Everyone') }
@ -1127,10 +1134,6 @@ export default React.createClass({
let avatarNode; let avatarNode;
let nameNode; let nameNode;
let shortDescNode; let shortDescNode;
const bodyNodes = [
this._getMembershipSection(),
this._getGroupSection(),
];
const rightButtons = []; const rightButtons = [];
if (this.state.editing && this.state.isUserPrivileged) { if (this.state.editing && this.state.isUserPrivileged) {
let avatarImage; let avatarImage;
@ -1207,6 +1210,7 @@ export default React.createClass({
shortDescNode = <span onClick={onGroupHeaderItemClick}>{ summary.profile.short_description }</span>; shortDescNode = <span onClick={onGroupHeaderItemClick}>{ summary.profile.short_description }</span>;
} }
} }
if (this.state.editing) { if (this.state.editing) {
rightButtons.push( rightButtons.push(
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton" <AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
@ -1231,6 +1235,11 @@ export default React.createClass({
</AccessibleButton>, </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) { if (this.props.collapsedRhs) {
rightButtons.push( rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button" <AccessibleButton className="mx_GroupHeader_button"
@ -1269,7 +1278,8 @@ export default React.createClass({
</div> </div>
</div> </div>
<GeminiScrollbarWrapper className="mx_GroupView_body"> <GeminiScrollbarWrapper className="mx_GroupView_body">
{ bodyNodes } { this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper> </GeminiScrollbarWrapper>
</div> </div>
); );

View file

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

View file

@ -23,6 +23,7 @@ import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics"; import Analytics from "../../Analytics";
import DecryptionFailureTracker from "../../DecryptionFailureTracker";
import MatrixClientPeg from "../../MatrixClientPeg"; import MatrixClientPeg from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
@ -1143,11 +1144,6 @@ export default React.createClass({
} else if (this._is_registered) { } else if (this._is_registered) {
this._is_registered = false; 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")) { if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
createRoom({ createRoom({
dmUserId: this.props.config.welcomeUserId, 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); const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => { cli.on("crypto.roomKeyRequest", (req) => {
krh.handleKeyRequest(req); krh.handleKeyRequest(req);

View file

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

View file

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

View file

@ -25,6 +25,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar'; import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend'; import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices'; import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
@ -157,10 +158,12 @@ module.exports = React.createClass({
_onResendAllClick: function() { _onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room); Resend.resendUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
}, },
_onCancelAllClick: function() { _onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room); Resend.cancelUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
}, },
_onShowDevicesClick: function() { _onShowDevicesClick: function() {
@ -305,7 +308,26 @@ module.exports = React.createClass({
}, },
); );
} else { } 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.length === 1 &&
unsentMessages[0].error && unsentMessages[0].error &&
unsentMessages[0].error.data && unsentMessages[0].error.data &&
@ -329,11 +351,13 @@ module.exports = React.createClass({
return <div className="mx_RoomStatusBar_connectionLostBar"> return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} /> <img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title"> <div>
{ title } <div className="mx_RoomStatusBar_connectionLostBar_title">
</div> { title }
<div className="mx_RoomStatusBar_connectionLostBar_desc"> </div>
{ content } <div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div> </div>
</div>; </div>;
}, },
@ -350,11 +374,13 @@ module.exports = React.createClass({
return ( return (
<div className="mx_RoomStatusBar_connectionLostBar"> <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " /> <img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title"> <div>
{ _t('Connectivity to the server has been lost.') } <div className="mx_RoomStatusBar_connectionLostBar_title">
</div> { _t('Connectivity to the server has been lost.') }
<div className="mx_RoomStatusBar_connectionLostBar_desc"> </div>
{ _t('Sent messages will be stored until your connection has returned.') } <div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
</div>
</div> </div>
</div> </div>
); );

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
'use strict'; import React from 'react';
import classNames from 'classnames';
var React = require('react'); import sdk from '../../index';
var ReactDOM = require('react-dom');
var classNames = require('classnames');
var sdk = require('../../index');
import { Droppable } from 'react-beautiful-dnd'; import { Droppable } from 'react-beautiful-dnd';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
var dis = require('../../dispatcher'); import dis from '../../dispatcher';
var Unread = require('../../Unread'); import Unread from '../../Unread';
var MatrixClientPeg = require('../../MatrixClientPeg'); import * as RoomNotifs from '../../RoomNotifs';
var RoomNotifs = require('../../RoomNotifs'); import * as FormattingUtils from '../../utils/FormattingUtils';
var FormattingUtils = require('../../utils/FormattingUtils');
var AccessibleButton = require('../../components/views/elements/AccessibleButton');
import Modal from '../../Modal';
import { KeyCode } from '../../Keyboard'; import { KeyCode } from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
// turn this on for drop & drag console debugging galore // turn this on for drop & drag console debugging galore
var debug = false; const debug = false;
const TRUNCATE_AT = 10; const TRUNCATE_AT = 10;
var RoomSubList = React.createClass({ const RoomSubList = React.createClass({
displayName: 'RoomSubList', displayName: 'RoomSubList',
debug: debug, debug: debug,
@ -77,8 +73,10 @@ var RoomSubList = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
onHeaderClick: function() {}, // NOP onHeaderClick: function() {
onShowMoreRooms: function() {}, // NOP }, // NOP
onShowMoreRooms: function() {
}, // NOP
extraTiles: [], extraTiles: [],
isInvite: false, isInvite: false,
}; };
@ -105,15 +103,17 @@ var RoomSubList = React.createClass({
applySearchFilter: function(list, filter) { applySearchFilter: function(list, filter) {
if (filter === "") return list; if (filter === "") return list;
return list.filter((room) => { const lcFilter = filter.toLowerCase();
return room.name && room.name.toLowerCase().indexOf(filter.toLowerCase()) >= 0 // 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 header is collapsable if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() { isCollapsableOnClick: function() {
var stuck = this.refs.header.dataset.stuck; const stuck = this.refs.header.dataset.stuck;
if (this.state.hidden || stuck === undefined || stuck === "none") { if (this.state.hidden || stuck === undefined || stuck === "none") {
return true; return true;
} else { } else {
@ -139,12 +139,12 @@ var RoomSubList = React.createClass({
onClick: function(ev) { onClick: function(ev) {
if (this.isCollapsableOnClick()) { if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic // The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
var isHidden = !this.state.hidden; const isHidden = !this.state.hidden;
this.setState({ hidden : isHidden }); this.setState({hidden: isHidden});
if (isHidden) { if (isHidden) {
// as good a way as any to reset the truncate state // as good a way as any to reset the truncate state
this.setState({ truncateAt : TRUNCATE_AT }); this.setState({truncateAt: TRUNCATE_AT});
} }
this.props.onShowMoreRooms(); this.props.onShowMoreRooms();
@ -159,7 +159,7 @@ var RoomSubList = React.createClass({
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: roomId, 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) { _shouldShowMentionBadge: function(roomNotifState) {
return roomNotifState != RoomNotifs.MUTE; return roomNotifState !== RoomNotifs.MUTE;
}, },
/** /**
* Total up all the notification counts from the rooms * 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 * @returns {Array} The array takes the form [total, highlight] where highlight is a bool
*/ */
roomNotificationCount: function(truncateAt) { roomNotificationCount: function(truncateAt) {
var self = this; const self = this;
if (this.props.isInvite) { if (this.props.isInvite) {
return [0, true]; return [0, true];
@ -187,9 +187,9 @@ var RoomSubList = React.createClass({
return this.props.list.reduce(function(result, room, index) { return this.props.list.reduce(function(result, room, index) {
if (truncateAt === undefined || index >= truncateAt) { if (truncateAt === undefined || index >= truncateAt) {
var roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId); const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
var highlight = room.getUnreadNotificationCount('highlight') > 0; const highlight = room.getUnreadNotificationCount('highlight') > 0;
var notificationCount = room.getUnreadNotificationCount(); const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState); const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(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() { _getHeaderJsx: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
var subListNotifications = this.roomNotificationCount(); const totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
var subListNotifCount = subListNotifications[0]; const roomCount = totalTiles > 0 ? totalTiles : '';
var subListNotifHighlight = subListNotifications[1];
var totalTiles = this.props.list.length + (this.props.extraTiles || []).length; const chevronClasses = classNames({
var roomCount = totalTiles > 0 ? totalTiles : '';
var chevronClasses = classNames({
'mx_RoomSubList_chevron': true, 'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden, 'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden, 'mx_RoomSubList_chevronDown': !this.state.hidden,
}); });
var badgeClasses = classNames({ const badgeClasses = classNames({
'mx_RoomSubList_badge': true, 'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight, 'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
}); });
var badge; let badge;
if (subListNotifCount > 0) { 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) { } else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge // 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 // When collapsed, allow a long hover on the header to show user
// the full tag name and room count // the full tag name and room count
var title; let title;
if (this.props.collapsed) { if (this.props.collapsed) {
title = this.props.label; title = this.props.label;
if (roomCount !== '') { if (roomCount !== '') {
@ -277,63 +322,66 @@ var RoomSubList = React.createClass({
} }
} }
var incomingCall; let incomingCall;
if (this.props.incomingCall) { if (this.props.incomingCall) {
var self = this; const self = this;
// Check if the incoming call is for this section // 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; return self.props.incomingCall.roomId === room.roomId;
}); });
if (incomingCallRoom.length === 1) { if (incomingCallRoom.length === 1) {
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox"); const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>; 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 ( return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header"> <div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}> <AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex}>
{ this.props.collapsed ? '' : this.props.label } {this.props.collapsed ? '' : this.props.label}
<div className="mx_RoomSubList_roomCount">{ roomCount }</div> <div className="mx_RoomSubList_roomCount">{roomCount}</div>
<div className={chevronClasses}></div> <div className={chevronClasses} />
{ badge } {badge}
{ incomingCall } {incomingCall}
</AccessibleButton> </AccessibleButton>
</div> </div>
); );
}, },
_createOverflowTile: function(overflowCount, totalCount) { _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); const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
var overflowNotifCount = overflowNotifications[0]; const overflowNotifCount = overflowNotifications[0];
var overflowNotifHighlight = overflowNotifications[1]; const overflowNotifHighlight = overflowNotifications[1];
if (overflowNotifCount && !this.props.collapsed) { if (overflowNotifCount && !this.props.collapsed) {
content = FormattingUtils.formatCount(overflowNotifCount); content = FormattingUtils.formatCount(overflowNotifCount);
} }
var badgeClasses = classNames({ const badgeClasses = classNames({
'mx_RoomSubList_moreBadge': true, 'mx_RoomSubList_moreBadge': true,
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed, 'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed, 'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
}); });
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return ( return (
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}> <AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
<div className="mx_RoomSubList_line"></div> <div className="mx_RoomSubList_line" />
<div className="mx_RoomSubList_more">{ _t("more") }</div> <div className="mx_RoomSubList_more">{_t("more")}</div>
<div className={ badgeClasses }>{ content }</div> <div className={badgeClasses}>{content}</div>
</AccessibleButton> </AccessibleButton>
); );
}, },
_showFullMemberList: function() { _showFullMemberList: function() {
this.setState({ this.setState({
truncateAt: -1 truncateAt: -1,
}); });
this.props.onShowMoreRooms(); this.props.onShowMoreRooms();
@ -341,37 +389,39 @@ var RoomSubList = React.createClass({
}, },
render: function() { render: function() {
var connectDropTarget = this.props.connectDropTarget; const TruncatedList = sdk.getComponent('elements.TruncatedList');
var TruncatedList = sdk.getComponent('elements.TruncatedList');
var label = this.props.collapsed ? null : this.props.label;
let content; let content;
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) { if (this.state.sortedList.length === 0 && this.props.extraTiles.length === 0) {
content = this.props.emptyContent; // 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 { } else {
content = this.makeRoomTiles(); content = this.makeRoomTiles();
content.push(...this.props.extraTiles); content.push(...this.props.extraTiles);
} }
if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) { if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) {
var subList; let subList;
var classes = "mx_RoomSubList"; const classes = "mx_RoomSubList";
if (!this.state.hidden) { if (!this.state.hidden) {
subList = <TruncatedList className={ classes } truncateAt={this.state.truncateAt} subList = <TruncatedList className={classes} truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile} > createOverflowElement={this._createOverflowTile}>
{ content } {content}
</TruncatedList>; </TruncatedList>;
} } else {
else { subList = <TruncatedList className={classes}>
subList = <TruncatedList className={ classes }> </TruncatedList>;
</TruncatedList>;
} }
const subListContent = <div> const subListContent = <div>
{ this._getHeaderJsx() } {this._getHeaderJsx()}
{ subList } {subList}
</div>; </div>;
return this.props.editable ? return this.props.editable ?
@ -379,23 +429,26 @@ var RoomSubList = React.createClass({
droppableId={"room-sub-list-droppable_" + this.props.tagName} droppableId={"room-sub-list-droppable_" + this.props.tagName}
type="draggable-RoomTile" type="draggable-RoomTile"
> >
{ (provided, snapshot) => ( {(provided, snapshot) => (
<div ref={provided.innerRef}> <div ref={provided.innerRef}>
{ subListContent } {subListContent}
</div> </div>
) } )}
</Droppable> : subListContent; </Droppable> : subListContent;
} } else {
else { const Loader = sdk.getComponent("elements.Spinner");
var Loader = sdk.getComponent("elements.Spinner"); if (this.props.showSpinner) {
content = <Loader />;
}
return ( return (
<div className="mx_RoomSubList"> <div className="mx_RoomSubList">
{ this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined } {this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined}
{ (this.props.showSpinner && !this.state.hidden) ? <Loader /> : undefined } { this.state.hidden ? undefined : content }
</div> </div>
); );
} }
} },
}); });
module.exports = RoomSubList; module.exports = RoomSubList;

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
const DEBUG = false; const DEBUG = false;
let debuglog = function() {}; let debuglog = function() {};
@ -317,14 +319,7 @@ module.exports = React.createClass({
return false; return false;
} }
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); return WidgetUtils.getRoomWidgets(room).length > 0;
// 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;
}, },
componentDidMount: function() { 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({ this.setState({
showUrlPreview: SettingsStore.getValue("urlPreviewsEnabled", room.roomId), showUrlPreview: SettingsStore.getValue(key, roomId),
}); });
}, },
@ -645,19 +642,23 @@ module.exports = React.createClass({
}, },
onAccountData: function(event) { 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); this._updatePreviewUrlVisibility(this.state.room);
} }
}, },
onRoomAccountData: function(event, room) { onRoomAccountData: function(event, room) {
if (room.roomId == this.state.roomId) { 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(); const color_scheme = event.getContent();
// XXX: we should validate the event // XXX: we should validate the event
console.log("Tinter.tint from onRoomAccountData"); console.log("Tinter.tint from onRoomAccountData");
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); 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); this._updatePreviewUrlVisibility(room);
} }
} }
@ -913,6 +914,8 @@ module.exports = React.createClass({
}, },
uploadFile: async function(file) { uploadFile: async function(file) {
dis.dispatch({action: 'focus_composer'});
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'}); dis.dispatch({action: 'view_set_mxid'});
return; 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd'; import { Droppable } from 'react-beautiful-dnd';
import classNames from 'classnames';
const TagPanel = React.createClass({ const TagPanel = React.createClass({
displayName: 'TagPanel', displayName: 'TagPanel',
@ -84,7 +85,10 @@ const TagPanel = React.createClass({
}, },
onMouseDown(e) { 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) { onCreateGroupClick(ev) {
@ -113,17 +117,26 @@ const TagPanel = React.createClass({
/>; />;
}); });
const clearButton = this.state.selectedTags.length > 0 ? const itemsSelected = this.state.selectedTags.length > 0;
<TintableSvg src="img/icons-close.svg" width="24" height="24"
alt={_t("Clear filter")}
title={_t("Clear filter")}
/> :
<div />;
return <div className="mx_TagPanel"> let clearButton;
<AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}> 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 } { clearButton }
</AccessibleButton> </div>
<div className="mx_TagPanel_divider" /> <div className="mx_TagPanel_divider" />
<GeminiScrollbarWrapper <GeminiScrollbarWrapper
className="mx_TagPanel_scroller" className="mx_TagPanel_scroller"

View file

@ -298,6 +298,7 @@ module.exports = React.createClass({
if (this._unmounted) return; if (this._unmounted) return;
this.setState({ this.setState({
mediaDevices, mediaDevices,
activeAudioOutput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audiooutput'),
activeAudioInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audioinput'), activeAudioInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audioinput'),
activeVideoInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_videoinput'), 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", "push notifications on other devices until you log back in to them",
) + ".", ) + ".",
}); });
dis.dispatch({action: 'password_changed'});
}, },
_onAddEmailEditFinished: function(value, shouldSubmit) { _onAddEmailEditFinished: function(value, shouldSubmit) {
@ -976,6 +976,11 @@ module.exports = React.createClass({
return devices.map((device) => <span key={device.deviceId}>{ device.label }</span>); return devices.map((device) => <span key={device.deviceId}>{ device.label }</span>);
}, },
_setAudioOutput: function(deviceId) {
this.setState({activeAudioOutput: deviceId});
CallMediaHandler.setAudioOutput(deviceId);
},
_setAudioInput: function(deviceId) { _setAudioInput: function(deviceId) {
this.setState({activeAudioInput: deviceId}); this.setState({activeAudioInput: deviceId});
CallMediaHandler.setAudioInput(deviceId); CallMediaHandler.setAudioInput(deviceId);
@ -1016,6 +1021,7 @@ module.exports = React.createClass({
const Dropdown = sdk.getComponent('elements.Dropdown'); const Dropdown = sdk.getComponent('elements.Dropdown');
let speakerDropdown = <p>{ _t('No Audio Outputs detected') }</p>;
let microphoneDropdown = <p>{ _t('No Microphones detected') }</p>; let microphoneDropdown = <p>{ _t('No Microphones detected') }</p>;
let webcamDropdown = <p>{ _t('No Webcams detected') }</p>; let webcamDropdown = <p>{ _t('No Webcams detected') }</p>;
@ -1024,6 +1030,26 @@ module.exports = React.createClass({
label: _t('Default Device'), 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); const audioInputs = this.state.mediaDevices.audioinput.slice(0);
if (audioInputs.length > 0) { if (audioInputs.length > 0) {
let defaultInput = ''; let defaultInput = '';
@ -1065,8 +1091,9 @@ module.exports = React.createClass({
} }
return <div> return <div>
{ microphoneDropdown } { speakerDropdown }
{ webcamDropdown } { microphoneDropdown }
{ webcamDropdown }
</div>; </div>;
}, },
@ -1080,6 +1107,14 @@ module.exports = React.createClass({
</div>; </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) { _showSpoiler: function(event) {
const target = event.target; const target = event.target;
target.innerHTML = target.getAttribute('data-spoiler'); target.innerHTML = target.getAttribute('data-spoiler');
@ -1301,10 +1336,13 @@ module.exports = React.createClass({
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
<div className="mx_UserSettings_advanced"> <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>
<div className="mx_UserSettings_advanced"> <div className="mx_UserSettings_advanced">
{ _t('Access Token:') } { _t('Access Token:') + ' ' }
<span className="mx_UserSettings_advanced_spoiler" <span className="mx_UserSettings_advanced_spoiler"
onClick={this._showSpoiler} onClick={this._showSpoiler}
data-spoiler={MatrixClientPeg.get().getAccessToken()}> data-spoiler={MatrixClientPeg.get().getAccessToken()}>

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -45,6 +43,8 @@ module.exports = React.createClass({
enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl, enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl,
enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl, enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl,
progress: null, progress: null,
password: null,
password2: null,
}; };
}, },
@ -103,7 +103,7 @@ module.exports = React.createClass({
</div>, </div>,
button: _t('Continue'), button: _t('Continue'),
extraButtons: [ extraButtons: [
<button className="mx_Dialog_primary" <button key="export_keys" className="mx_Dialog_primary"
onClick={this._onExportE2eKeysClicked}> onClick={this._onExportE2eKeysClicked}>
{ _t('Export E2E room keys') } { _t('Export E2E room keys') }
</button>, </button>,
@ -169,7 +169,8 @@ module.exports = React.createClass({
} else if (this.state.progress === "sent_email") { } else if (this.state.progress === "sent_email") {
resetPasswordJsx = ( resetPasswordJsx = (
<div className="mx_Login_prompt"> <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 /> <br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify} <input className="mx_Login_submit" type="button" onClick={this.onVerify}
value={_t('I have verified my email address')} /> value={_t('I have verified my email address')} />
@ -179,14 +180,15 @@ module.exports = React.createClass({
resetPasswordJsx = ( resetPasswordJsx = (
<div className="mx_Login_prompt"> <div className="mx_Login_prompt">
<p>{ _t('Your password has been reset') }.</p> <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} <input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value={_t('Return to login screen')} /> value={_t('Return to login screen')} />
</div> </div>
); );
} else { } else {
let serverConfigSection; let serverConfigSection;
if (!SdkConfig.get().disable_custom_urls) { if (!SdkConfig.get()['disable_custom_urls']) {
serverConfigSection = ( serverConfigSection = (
<ServerConfig ref="serverConfig" <ServerConfig ref="serverConfig"
withToggleButton={true} withToggleButton={true}
@ -199,6 +201,8 @@ module.exports = React.createClass({
); );
} }
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
resetPasswordJsx = ( resetPasswordJsx = (
<div> <div>
<div className="mx_Login_prompt"> <div className="mx_Login_prompt">
@ -233,6 +237,7 @@ module.exports = React.createClass({
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#"> <a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
{ _t('Create an account') } { _t('Create an account') }
</a> </a>
<LanguageSelector />
<LoginFooter /> <LoginFooter />
</div> </div>
</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 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import Login from '../../../Login'; import Login from '../../../Login';
import PlatformPeg from '../../../PlatformPeg';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
// For validating phone numbers without country codes // 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 * A wire component which glues together login UI components and Login logic
@ -94,6 +93,13 @@ module.exports = React.createClass({
this._unmounted = true; this._unmounted = true;
}, },
onPasswordLoginError: function(errorText) {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
this.setState({ this.setState({
busy: true, busy: true,
@ -113,10 +119,10 @@ module.exports = React.createClass({
// Some error strings only apply for logging in // Some error strings only apply for logging in
const usingEmail = username.indexOf("@") > 0; 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.'); errorText = _t('This Home Server does not support login using email address.');
} else if (error.httpStatus === 401 || error.httpStatus === 403) { } else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (SdkConfig.get().disable_custom_urls) { if (SdkConfig.get()['disable_custom_urls']) {
errorText = ( errorText = (
<div> <div>
<div>{ _t('Incorrect username and/or password.') }</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 // 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) // mentions this (although the bug is for UI auth which is not this)
// We treat both as an incorrect password // We treat both as an incorrect password
loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403, loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403,
}); });
}).finally(() => { }).finally(() => {
if (this._unmounted) { if (this._unmounted) {
@ -231,7 +237,7 @@ module.exports = React.createClass({
hsUrl = hsUrl || this.state.enteredHomeserverUrl; hsUrl = hsUrl || this.state.enteredHomeserverUrl;
isUrl = isUrl || this.state.enteredIdentityServerUrl; 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, { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, {
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
@ -310,19 +316,27 @@ module.exports = React.createClass({
!this.state.enteredHomeserverUrl.startsWith("http")) !this.state.enteredHomeserverUrl.startsWith("http"))
) { ) {
errorText = <span> errorText = <span>
{ { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
_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>.", {},
"Either use HTTPS or <a>enable unsafe scripts</a>.", {
{}, 'a': (sub) => {
{ 'a': (sub) => { return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">{ sub }</a>; } }, return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">
{ sub }
</a>;
},
},
) } ) }
</span>; </span>;
} else { } else {
errorText = <span> errorText = <span>
{ { _t("Can't connect to homeserver - please check your connectivity, ensure your " +
_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>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>; } }, {
'a': (sub) => {
return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>;
},
},
) } ) }
</span>; </span>;
} }
@ -350,6 +364,7 @@ module.exports = React.createClass({
return ( return (
<PasswordLogin <PasswordLogin
onSubmit={this.onPasswordLogin} onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
initialUsername={this.state.username} initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry} initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber} 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() { render: function() {
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const LoginPage = sdk.getComponent("login.LoginPage"); const LoginPage = sdk.getComponent("login.LoginPage");
@ -403,21 +401,10 @@ module.exports = React.createClass({
</a>; </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 serverConfig;
let header; let header;
if (!SdkConfig.get().disable_custom_urls) { if (!SdkConfig.get()['disable_custom_urls']) {
serverConfig = <ServerConfig ref="serverConfig" serverConfig = <ServerConfig ref="serverConfig"
withToggleButton={true} withToggleButton={true}
customHsUrl={this.props.customHsUrl} customHsUrl={this.props.customHsUrl}
@ -447,6 +434,8 @@ module.exports = React.createClass({
); );
} }
const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector');
return ( return (
<LoginPage> <LoginPage>
<div className="mx_Login_box"> <div className="mx_Login_box">
@ -460,8 +449,7 @@ module.exports = React.createClass({
{ _t('Create an account') } { _t('Create an account') }
</a> </a>
{ loginAsGuestJsx } { loginAsGuestJsx }
{ returnToAppJsx } <LanguageSelector />
{ !SdkConfig.get().disable_login_language_selector ? this._renderLanguageSetting() : '' }
<LoginFooter /> <LoginFooter />
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import ServerConfig from '../../views/login/ServerConfig';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import RegistrationForm from '../../views/login/RegistrationForm'; import RegistrationForm from '../../views/login/RegistrationForm';
import RtsClient from '../../../RtsClient'; import RtsClient from '../../../RtsClient';
@ -62,6 +62,12 @@ module.exports = React.createClass({
onLoginClick: PropTypes.func.isRequired, onLoginClick: PropTypes.func.isRequired,
onCancelClick: PropTypes.func, onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired, onServerConfigChange: PropTypes.func.isRequired,
rtsClient: PropTypes.shape({
getTeamsConfig: PropTypes.func.isRequired,
trackReferral: PropTypes.func.isRequired,
getTeam: PropTypes.func.isRequired,
}),
}, },
getInitialState: function() { getInitialState: function() {
@ -133,7 +139,7 @@ module.exports = React.createClass({
newState.isUrl = config.isUrl; newState.isUrl = config.isUrl;
} }
this.props.onServerConfigChange(config); this.props.onServerConfigChange(config);
this.setState(newState, function() { this.setState(newState, () => {
this._replaceClient(); this._replaceClient();
}); });
}, },
@ -159,11 +165,11 @@ module.exports = React.createClass({
let msg = response.message || response.toString(); let msg = response.message || response.toString();
// can we give a better error message? // can we give a better error message?
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { 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) { 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.'); 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)=>{ return matrixClient.getPushers().then((resp)=>{
const pushers = resp.pushers; const pushers = resp.pushers;
for (let i = 0; i < pushers.length; ++i) { for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind == 'email') { if (pushers[i].kind === 'email') {
const emailPusher = pushers[i]; const emailPusher = pushers[i];
emailPusher.data = { brand: this.props.brand }; emailPusher.data = { brand: this.props.brand };
matrixClient.setPusher(emailPusher).done(() => { matrixClient.setPusher(emailPusher).done(() => {
@ -267,7 +273,7 @@ module.exports = React.createClass({
errMsg = _t('Passwords don\'t match.'); errMsg = _t('Passwords don\'t match.');
break; break;
case "RegistrationForm.ERR_PASSWORD_LENGTH": 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; break;
case "RegistrationForm.ERR_EMAIL_INVALID": case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = _t('This doesn\'t look like a valid email address.'); errMsg = _t('This doesn\'t look like a valid email address.');
@ -353,7 +359,7 @@ module.exports = React.createClass({
registerBody = <Spinner />; registerBody = <Spinner />;
} else { } else {
let serverConfigSection; let serverConfigSection;
if (!SdkConfig.get().disable_custom_urls) { if (!SdkConfig.get()['disable_custom_urls']) {
serverConfigSection = ( serverConfigSection = (
<ServerConfig ref="serverConfig" <ServerConfig ref="serverConfig"
withToggleButton={true} 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 header;
let errorText; let errorText;
// FIXME: remove hardcoded Status team tweaks at some point // 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 ( return (
<LoginPage> <LoginPage>
<div className="mx_Login_box"> <div className="mx_Login_box">
@ -431,7 +427,7 @@ module.exports = React.createClass({
{ registerBody } { registerBody }
{ signIn } { signIn }
{ errorText } { errorText }
{ returnToAppJsx } <LanguageSelector />
<LoginFooter /> <LoginFooter />
</div> </div>
</LoginPage> </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. limitations under the License.
*/ */
'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
@ -184,6 +183,15 @@ module.exports = React.createClass({
this.closeMenu(); 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() { onReplyClick: function() {
dis.dispatch({ dis.dispatch({
action: 'reply_to_event', action: 'reply_to_event',
@ -211,7 +219,10 @@ module.exports = React.createClass({
let replyButton; let replyButton;
let collapseReplyThread; 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 = ( resendButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onResendClick}> <div className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') } { _t('Resend') }
@ -219,7 +230,7 @@ module.exports = React.createClass({
); );
} }
if (!eventStatus && this.state.canRedact) { if (isSent && this.state.canRedact) {
redactButton = ( redactButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}> <div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
{ _t('Remove') } { _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 = ( cancelButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}> <div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') } { _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(); const content = this.props.mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) { if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = ( forwardButton = (
@ -244,13 +255,11 @@ module.exports = React.createClass({
</div> </div>
); );
if (SettingsStore.isFeatureEnabled("feature_rich_quoting")) { replyButton = (
replyButton = ( <div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}>
<div className="mx_MessageContextMenu_field" onClick={this.onReplyClick}> { _t('Reply') }
{ _t('Reply') } </div>
</div> );
);
}
if (this.state.canPin) { if (this.state.canPin) {
pinButton = ( pinButton = (
@ -290,7 +299,7 @@ module.exports = React.createClass({
const permalinkButton = ( const permalinkButton = (
<div className="mx_MessageContextMenu_field"> <div className="mx_MessageContextMenu_field">
<a href={makeEventPermalink(this.props.mxEvent.getRoomId(), this.props.mxEvent.getId())} <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> </div>
); );

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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, { Modal.createDialog(WarmFuzzy, {
didSetEmail: res.didSetEmail, didSetEmail: res.didSetEmail,
onFinished: () => { onFinished: () => {
this._onContinueClicked(); this.props.onFinished();
}, },
}); });
}, },
_onContinueClicked: function() {
this.props.onFinished(true);
},
_onPasswordChangeError: function(err) { _onPasswordChangeError: function(err) {
let errMsg = err.error || ""; let errMsg = err.error || "";
if (err.httpStatus === 403) { 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 PropTypes from 'prop-types';
import url from 'url'; import url from 'url';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import WidgetUtils from "../../../WidgetUtils"; import WidgetUtils from "../../../utils/WidgetUtils";
export default class AppPermission extends React.Component { export default class AppPermission extends React.Component {
constructor(props) { constructor(props) {

View file

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

View file

@ -139,8 +139,11 @@ module.exports = React.createClass({
</div> </div>
{ editableItems } { editableItems }
{ this.props.canEdit ? { 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 <EditableItem
key={-1} key={editableItems.length}
initialValue={this.props.newItem} initialValue={this.props.newItem}
onAdd={this.onItemAdded} onAdd={this.onItemAdded}
onChange={this.onNewItemChanged} onChange={this.onNewItemChanged}

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
'use strict'; import React from 'react';
const React = require('react');
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const KEY_TAB = 9;
const KEY_SHIFT = 16;
const KEY_WINDOWS = 91;
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'EditableText', displayName: 'EditableText',
@ -66,9 +61,7 @@ module.exports = React.createClass({
}, },
componentWillReceiveProps: function(nextProps) { componentWillReceiveProps: function(nextProps) {
if (nextProps.initialValue !== this.props.initialValue || if (nextProps.initialValue !== this.props.initialValue) {
nextProps.initialValue !== this.value
) {
this.value = nextProps.initialValue; this.value = nextProps.initialValue;
if (this.refs.editable_div) { if (this.refs.editable_div) {
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
@ -139,7 +132,7 @@ module.exports = React.createClass({
this.showPlaceholder(false); this.showPlaceholder(false);
} }
if (ev.key == "Enter") { if (ev.key === "Enter") {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
} }
@ -156,9 +149,9 @@ module.exports = React.createClass({
this.value = ev.target.textContent; this.value = ev.target.textContent;
} }
if (ev.key == "Enter") { if (ev.key === "Enter") {
this.onFinish(ev); this.onFinish(ev);
} else if (ev.key == "Escape") { } else if (ev.key === "Escape") {
this.cancelEdit(); this.cancelEdit();
} }
@ -193,7 +186,7 @@ module.exports = React.createClass({
const submit = (ev.key === "Enter") || shouldSubmit; const submit = (ev.key === "Enter") || shouldSubmit;
this.setState({ this.setState({
phase: this.Phases.Display, phase: this.Phases.Display,
}, function() { }, () => {
if (this.value !== this.props.initialValue) { if (this.value !== this.props.initialValue) {
self.onValueChanged(submit); self.onValueChanged(submit);
} }
@ -204,23 +197,35 @@ module.exports = React.createClass({
const sel = window.getSelection(); const sel = window.getSelection();
sel.removeAllRanges(); 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); this.showPlaceholder(!this.value);
}, },
render: function() { 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 // 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 { } else {
// show the content editable div, but manually manage its contents as react and contentEditable don't play nice together // 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} editableEl = <div ref="editable_div"
onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur}></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 // 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 * 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 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 MatrixClientPeg from '../../../MatrixClientPeg';
import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix'; import { MATRIXTO_URL_PATTERN } from '../../../linkify-matrix';
import { getDisplayAliasForRoom } from '../../../Rooms'; import { getDisplayAliasForRoom } from '../../../Rooms';
import FlairStore from "../../../stores/FlairStore";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
// For URLs of matrix.to links in the timeline which have been reformatted by // For URLs of matrix.to links in the timeline which have been reformatted by
// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`) // 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({ const Pill = React.createClass({
statics: { statics: {
@ -45,6 +47,7 @@ const Pill = React.createClass({
}, },
TYPE_USER_MENTION: 'TYPE_USER_MENTION', TYPE_USER_MENTION: 'TYPE_USER_MENTION',
TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION', TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION',
TYPE_GROUP_MENTION: 'TYPE_GROUP_MENTION',
TYPE_AT_ROOM_MENTION: 'TYPE_AT_ROOM_MENTION', // '@room' 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 // The member related to the user pill
member: null, member: null,
// The group related to the group pill
group: null,
// The room related to the room pill // The room related to the room pill
room: null, room: null,
}; };
}, },
componentWillReceiveProps(nextProps) { async componentWillReceiveProps(nextProps) {
let regex = REGEX_MATRIXTO; let regex = REGEX_MATRIXTO;
if (nextProps.inMessage) { if (nextProps.inMessage) {
regex = REGEX_LOCAL_MATRIXTO; regex = REGEX_LOCAL_MATRIXTO;
@ -109,9 +114,11 @@ const Pill = React.createClass({
'@': Pill.TYPE_USER_MENTION, '@': Pill.TYPE_USER_MENTION,
'#': Pill.TYPE_ROOM_MENTION, '#': Pill.TYPE_ROOM_MENTION,
'!': Pill.TYPE_ROOM_MENTION, '!': Pill.TYPE_ROOM_MENTION,
'+': Pill.TYPE_GROUP_MENTION,
}[prefix]; }[prefix];
let member; let member;
let group;
let room; let room;
switch (pillType) { switch (pillType) {
case Pill.TYPE_AT_ROOM_MENTION: { case Pill.TYPE_AT_ROOM_MENTION: {
@ -140,8 +147,21 @@ const Pill = React.createClass({
} }
} }
break; 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() { componentWillMount() {
@ -179,6 +199,7 @@ const Pill = React.createClass({
}); });
}, },
render: function() { render: function() {
const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar');
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
const RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
@ -229,6 +250,20 @@ const Pill = React.createClass({
} }
} }
break; 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, { const classes = classNames(pillClass, {

View file

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

View file

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

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
'use strict';
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
export default React.createClass({ export default React.createClass({
onUpdateClicked: function() { onUpdateClicked: function() {
const SetPasswordDialog = sdk.getComponent('dialogs.SetPasswordDialog'); const SetPasswordDialog = sdk.getComponent('dialogs.SetPasswordDialog');
Modal.createTrackedDialog('Set Password Dialog', 'Password Nag Bar', 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',
});
},
});
}, },
render: function() { 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
import MatrixClientPeg from "../../../MatrixClientPeg";
import {createMenu} from "../../structures/ContextualMenu";
export default React.createClass({ export default React.createClass({
displayName: 'GroupInviteTile', displayName: 'GroupInviteTile',
@ -32,6 +36,15 @@ export default React.createClass({
matrixClient: PropTypes.instanceOf(MatrixClient), 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) { onClick: function(e) {
dis.dispatch({ dis.dispatch({
action: 'view_group', 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() { render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const EmojiText = sdk.getComponent('elements.EmojiText'); 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 av = <BaseAvatar name={groupName} width={24} height={24} url={httpAvatarUrl} />;
const label = <EmojiText const nameClasses = classNames('mx_RoomTile_name mx_RoomTile_invite mx_RoomTile_badgeShown', {
element="div" 'mx_RoomTile_badgeShown': this.state.badgeHover || this.state.menuDisplayed,
title={this.props.group.groupId} });
className="mx_RoomTile_name mx_RoomTile_badgeShown"
dir="auto" const label = <EmojiText element="div" title={this.props.group.groupId} className={nameClasses} dir="auto">
>
{ groupName } { groupName }
</EmojiText>; </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 ( 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"> <div className="mx_RoomTile_avatar">
{ av } { av }
</div> </div>
@ -69,6 +166,7 @@ export default React.createClass({
{ label } { label }
{ badge } { badge }
</div> </div>
{ tooltip }
</AccessibleButton> </AccessibleButton>
); );
}, },

View file

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

View file

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

View file

@ -22,6 +22,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
function nop() {}
const GroupTile = React.createClass({ const GroupTile = React.createClass({
displayName: 'GroupTile', displayName: 'GroupTile',
@ -81,7 +82,7 @@ const GroupTile = React.createClass({
) : null; ) : null;
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273 // 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 // 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"> <Droppable droppableId="my-groups-droppable" type="draggable-TagTile">
{ (droppableProvided, droppableSnapshot) => ( { (droppableProvided, droppableSnapshot) => (
<div ref={droppableProvided.innerRef}> <div ref={droppableProvided.innerRef}>

View file

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

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd Copyright 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import ImageUtils from '../../../ImageUtils';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import Promise from 'bluebird'; import Promise from 'bluebird';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
export default class extends React.Component { export default class MImageBody extends React.Component {
displayName: 'MImageBody'
static propTypes = { static propTypes = {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired, mxEvent: PropTypes.object.isRequired,
@ -43,23 +38,21 @@ export default class extends React.Component {
/* the maximum image height to use */ /* the maximum image height to use */
maxImageHeight: PropTypes.number, maxImageHeight: PropTypes.number,
} };
static contextTypes = { static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient), matrixClient: PropTypes.instanceOf(MatrixClient),
} };
constructor(props) { constructor(props) {
super(props); super(props);
this.onAction = this.onAction.bind(this);
this.onImageError = this.onImageError.bind(this); this.onImageError = this.onImageError.bind(this);
this.onImageLoad = this.onImageLoad.bind(this); this.onImageLoad = this.onImageLoad.bind(this);
this.onImageEnter = this.onImageEnter.bind(this); this.onImageEnter = this.onImageEnter.bind(this);
this.onImageLeave = this.onImageLeave.bind(this); this.onImageLeave = this.onImageLeave.bind(this);
this.onClientSync = this.onClientSync.bind(this); this.onClientSync = this.onClientSync.bind(this);
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
this.fixupHeight = this.fixupHeight.bind(this);
this._isGif = this._isGif.bind(this); this._isGif = this._isGif.bind(this);
this.state = { this.state = {
@ -68,6 +61,9 @@ export default class extends React.Component {
decryptedBlob: null, decryptedBlob: null,
error: null, error: null,
imgError: false, imgError: false,
imgLoaded: false,
loadedImageDimensions: null,
hover: false,
}; };
} }
@ -91,7 +87,7 @@ export default class extends React.Component {
} }
onClick(ev) { onClick(ev) {
if (ev.button == 0 && !ev.metaKey) { if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault(); ev.preventDefault();
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const httpUrl = this._getContentUrl(); const httpUrl = this._getContentUrl();
@ -122,6 +118,8 @@ export default class extends React.Component {
} }
onImageEnter(e) { onImageEnter(e) {
this.setState({ hover: true });
if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
@ -130,6 +128,8 @@ export default class extends React.Component {
} }
onImageLeave(e) { onImageLeave(e) {
this.setState({ hover: false });
if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
@ -145,6 +145,16 @@ export default class extends React.Component {
onImageLoad() { onImageLoad() {
this.props.onWidgetLoad(); this.props.onWidgetLoad();
let loadedImageDimensions;
if (this.refs.image) {
const { naturalWidth, naturalHeight } = this.refs.image;
loadedImageDimensions = { naturalWidth, naturalHeight };
}
this.setState({ imgLoaded: true, loadedImageDimensions });
} }
_getContentUrl() { _getContentUrl() {
@ -164,9 +174,7 @@ export default class extends React.Component {
return this.state.decryptedThumbnailUrl; return this.state.decryptedThumbnailUrl;
} }
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else if (content.info && } else if (content.info && content.info.mimetype === "image/svg+xml" && content.info.thumbnail_url) {
content.info.mimetype == "image/svg+xml" &&
content.info.thumbnail_url) {
// special case to return client-generated thumbnails for SVGs, if any, // special case to return client-generated thumbnails for SVGs, if any,
// given we deliberately don't thumbnail them serverside to prevent // given we deliberately don't thumbnail them serverside to prevent
// billion lol attacks and similar // billion lol attacks and similar
@ -179,11 +187,10 @@ export default class extends React.Component {
} }
componentDidMount() { componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null); let thumbnailPromise = Promise.resolve(null);
if (content.info.thumbnail_file) { if (content.info && content.info.thumbnail_file) {
thumbnailPromise = decryptFile( thumbnailPromise = decryptFile(
content.info.thumbnail_file, content.info.thumbnail_file,
).then(function(blob) { ).then(function(blob) {
@ -210,7 +217,6 @@ export default class extends React.Component {
}); });
}).done(); }).done();
} }
this.fixupHeight();
this._afterComponentDidMount(); this._afterComponentDidMount();
} }
@ -221,7 +227,6 @@ export default class extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
dis.unregister(this.dispatcherRef);
this.context.matrixClient.removeListener('sync', this.onClientSync); this.context.matrixClient.removeListener('sync', this.onClientSync);
this._afterComponentWillUnmount(); this._afterComponentWillUnmount();
@ -238,60 +243,113 @@ export default class extends React.Component {
_afterComponentWillUnmount() { _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) { _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 = ( const thumbnail = (
<a href={contentUrl} onClick={this.onClick}> <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px" }} >
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image" { /* Calculate aspect ratio, using %padding will size _container correctly */ }
alt={content.body} <div style={{ paddingBottom: (100 * infoHeight / infoWidth) + '%' }} />
onError={this.onImageError}
onLoad={this.onImageLoad} { showPlaceholder &&
onMouseEnter={this.onImageEnter} <div className="mx_MImageBody_thumbnail" style={{
onMouseLeave={this.onImageLeave} /> // Constrain width here so that spinner appears central to the loaded thumbnail
</a> 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 ( return this.wrapImage(contentUrl, thumbnail);
<span className="mx_MImageBody" ref="body"> }
{ thumbUrl && !this.state.imgError ? thumbnail : '' }
<MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} /> // Overidden by MStickerBody
</span> 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() { 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(); const contentUrl = this._getContentUrl();
let thumbUrl; let thumbUrl;
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
@ -333,6 +372,12 @@ export default class extends React.Component {
thumbUrl = this._getThumbUrl(); 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 MImageBody from './MImageBody';
import sdk from '../../../index'; import sdk from '../../../index';
import TintableSVG from '../elements/TintableSvg';
export default class MStickerBody extends MImageBody { 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 // Empty to prevent default behaviour of MImageBody
onClick() { 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. // For now add an img tag with a spinner.
return ( return (
<span className="mx_MVideoBody" ref="body"> <span className="mx_MVideoBody" ref="body">
<div className="mx_MImageBody_thumbnail" ref="image" style={{ <div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner" ref="image">
"display": "flex",
"align-items": "center",
"justify-items": "center",
"width": "100%",
}}>
<img src="img/spinner.gif" alt={content.body} width="16" height="16" /> <img src="img/spinner.gif" alt={content.body} width="16" height="16" />
</div> </div>
</span> </span>

View file

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

View file

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

View file

@ -36,6 +36,7 @@ import * as ContextualMenu from '../../structures/ContextualMenu';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor'; import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import {host as matrixtoHost} from '../../../matrix-to';
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -304,7 +305,7 @@ module.exports = React.createClass({
// never preview matrix.to links (if anything we should give a smart // 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 // preview of the room/user they point to: nobody needs to be reminded
// what the matrix.to site looks like). // 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())) { if (node.textContent.toLowerCase().trim().startsWith(host.toLowerCase())) {
// it's a "foo.pl" style link // it's a "foo.pl" style link
@ -336,10 +337,21 @@ module.exports = React.createClass({
left: x, left: x,
top: y, top: y,
message: successful ? _t('Copied!') : _t('Failed to copy'), message: successful ? _t('Copied!') : _t('Failed to copy'),
}); }, false);
e.target.onmouseout = close; 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 mxEvent = this.props.mxEvent;
const content = mxEvent.getContent(); const content = mxEvent.getContent();
const stripReply = SettingsStore.isFeatureEnabled("feature_rich_quoting") && const stripReply = ReplyThread.getParentEventId(mxEvent);
ReplyThread.getParentEventId(mxEvent);
let body = HtmlUtils.bodyToHtml(content, this.props.highlights, { let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
disableBigEmoji: SettingsStore.getValue('TextualBody.disableBigEmoji'), disableBigEmoji: SettingsStore.getValue('TextualBody.disableBigEmoji'),
// Part of Replies fallback support // Part of Replies fallback support

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 Travis Ralston Copyright 2017 Travis Ralston
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
import {MatrixClient} from "matrix-js-sdk";
const React = require('react'); const React = require('react');
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
const sdk = require("../../../index"); const sdk = require("../../../index");
@ -29,6 +31,10 @@ module.exports = React.createClass({
room: PropTypes.object, room: PropTypes.object,
}, },
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
},
saveSettings: function() { saveSettings: function() {
const promises = []; const promises = [];
if (this.refs.urlPreviewsRoom) promises.push(this.refs.urlPreviewsRoom.save()); if (this.refs.urlPreviewsRoom) promises.push(this.refs.urlPreviewsRoom.save());
@ -39,42 +45,58 @@ module.exports = React.createClass({
render: function() { render: function() {
const SettingsFlag = sdk.getComponent("elements.SettingsFlag"); const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
const roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
const isEncrypted = this.context.matrixClient.isRoomEncrypted(roomId);
let previewsForAccount = null; 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; let previewsForRoom = null;
if (SettingsStore.canSetValue("urlPreviewsEnabled", roomId, "room")) {
previewsForRoom = ( if (!isEncrypted) {
<label> // Only show account setting state and room state setting state in non-e2ee rooms where they apply
<SettingsFlag name="urlPreviewsEnabled" const accountEnabled = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "urlPreviewsEnabled");
level={SettingLevel.ROOM} if (accountEnabled) {
roomId={this.props.room.roomId} previewsForAccount = (
isExplicit={true} _t("You have <a>enabled</a> URL previews by default.", {}, {
manualSave={true} 'a': (sub)=><a href="#/settings">{ sub }</a>,
ref="urlPreviewsRoom" /> })
</label> );
); } else if (accountEnabled) {
} else { previewsForAccount = (
let str = _td("URL previews are enabled by default for participants in this room."); _t("You have <a>disabled</a> URL previews by default.", {}, {
if (!SettingsStore.getValueAt(SettingLevel.ROOM, "urlPreviewsEnabled", roomId, /*explicit=*/true)) { 'a': (sub)=><a href="#/settings">{ sub }</a>,
str = _td("URL previews are disabled by default for participants in this room."); })
);
} }
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 = ( const previewsForRoomAccount = ( // in an e2ee room we use a special key to enforce per-room opt-in
<SettingsFlag name="urlPreviewsEnabled" <SettingsFlag name={isEncrypted ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'}
level={SettingLevel.ROOM_ACCOUNT} level={SettingLevel.ROOM_ACCOUNT}
roomId={this.props.room.roomId} roomId={roomId}
manualSave={true} manualSave={true}
ref="urlPreviewsSelf" ref="urlPreviewsSelf"
/> />
@ -83,8 +105,13 @@ module.exports = React.createClass({
return ( return (
<div className="mx_RoomSettings_toggles"> <div className="mx_RoomSettings_toggles">
<h3>{ _t("URL Previews") }</h3> <h3>{ _t("URL Previews") }</h3>
<div>
<label>{ previewsForAccount }</label> { _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 } { previewsForRoom }
<label>{ previewsForRoomAccount }</label> <label>{ previewsForRoomAccount }</label>
</div> </div>

View file

@ -27,7 +27,7 @@ import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging'; import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
// The maximum number of widgets that can be added in a room // 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'; const hideWidgetKey = this.props.room.roomId + '_hide_widget_drawer';
switch (action.action) { switch (action.action) {
case 'appsDrawer': 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) { if (action.show) {
const apps = this._getApps();
if (apps.length === 0) {
this._launchManageIntegrations();
}
localStorage.removeItem(hideWidgetKey); localStorage.removeItem(hideWidgetKey);
} else { } else {
// Store hidden state of widget // Store hidden state of widget
@ -171,14 +163,7 @@ module.exports = React.createClass({
}, },
_getApps: function() { _getApps: function() {
const appsStateEvents = this.props.room.currentState.getStateEvents('im.vector.modular.widgets'); return WidgetUtils.getRoomWidgets(this.props.room).map((ev) => {
if (!appsStateEvents) {
return [];
}
return appsStateEvents.filter((ev) => {
return ev.getContent().type && ev.getContent().url;
}).map((ev) => {
return this._initAppConfig(ev.getStateKey(), ev.getContent(), ev.sender); 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 dis from '../../../dispatcher';
import {makeEventPermalink} from "../../../matrix-to"; import {makeEventPermalink} from "../../../matrix-to";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {EventStatus} from 'matrix-js-sdk';
const ObjectUtils = require('../../../ObjectUtils'); const ObjectUtils = require('../../../ObjectUtils');
@ -442,26 +443,27 @@ module.exports = withMatrixClient(React.createClass({
const ev = this.props.mxEvent; const ev = this.props.mxEvent;
const props = {onClick: this.onCryptoClicked}; const props = {onClick: this.onCryptoClicked};
// event could not be decrypted
if (ev.getContent().msgtype === 'm.bad.encrypted') { if (ev.getContent().msgtype === 'm.bad.encrypted') {
return <E2ePadlockUndecryptable {...props} />; 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 // event is encrypted, display padlock corresponding to whether or not it is verified
// open padlock if (ev.isEncrypted()) {
const e2eEnabled = this.props.matrixClient.isRoomEncrypted(ev.getRoomId()); return this.state.verified ? <E2ePadlockVerified {...props} /> : <E2ePadlockUnverified {...props} />;
if (e2eEnabled) { }
return <E2ePadlockUnencrypted {...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 // no padlock needed
@ -490,7 +492,7 @@ module.exports = withMatrixClient(React.createClass({
} }
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); 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 isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const classes = classNames({ const classes = classNames({
@ -608,13 +610,14 @@ module.exports = withMatrixClient(React.createClass({
switch (this.props.tileShape) { switch (this.props.tileShape) {
case 'notif': { case 'notif': {
const EmojiText = sdk.getComponent('elements.EmojiText');
const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId());
return ( return (
<div className={classes}> <div className={classes}>
<div className="mx_EventTile_roomName"> <div className="mx_EventTile_roomName">
<a href={permalink} onClick={this.onPermalinkClicked}> <EmojiText element="a" href={permalink} onClick={this.onPermalinkClicked}>
{ room ? room.name : '' } { room ? room.name : '' }
</a> </EmojiText>
</div> </div>
<div className="mx_EventTile_senderDetails"> <div className="mx_EventTile_senderDetails">
{ avatar } { 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) { module.exports.haveTileForEvent = function(e) {
// Only messages have a tile (black-rectangle) if redacted // 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); const handler = getHandlerTile(e);
if (handler === undefined) return false; 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) { function E2ePadlockVerified(props) {
return ( return (
<E2ePadlock alt={_t("Encrypted by a verified device")} <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() { _renderUserOptions: function() {
const cli = this.props.matrixClient; const cli = this.props.matrixClient;
const member = this.props.member; 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 ( return (
<div> <div>
<h3>{ _t("User Options") }</h3> <h3>{ _t("User Options") }</h3>
<div className="mx_MemberInfo_buttons"> <div className="mx_MemberInfo_buttons">
{ readReceiptButton } { readReceiptButton }
{ shareUserButton }
{ insertPillButton } { insertPillButton }
{ ignoreButton } { ignoreButton }
{ inviteUserButton } { inviteUserButton }
@ -922,7 +934,9 @@ module.exports = withMatrixClient(React.createClass({
return ( return (
<div className="mx_MemberInfo"> <div className="mx_MemberInfo">
<GeminiScrollbarWrapper autoshow={true}> <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"> <div className="mx_MemberInfo_avatar">
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} /> <MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
</div> </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) { onCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video", type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
// this._startCallApp(false);
} }
onVoiceCallClick(ev) { onVoiceCallClick(ev) {
// NOTE -- Will be replaced by Jitsi code (currently commented)
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: "voice", type: "voice",
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
// this._startCallApp(true);
} }
onInputContentChanged(content: string, selection: {start: number, end: number}) { onInputContentChanged(content: string, selection: {start: number, end: number}) {

View file

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

View file

@ -149,6 +149,13 @@ module.exports = React.createClass({
dis.dispatch({ action: 'show_right_panel' }); 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() { _hasUnreadPins: function() {
const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", ''); const currentPinEvent = this.props.room.currentState.getStateEvents("m.room.pinned_events", '');
if (!currentPinEvent) return false; if (!currentPinEvent) return false;
@ -379,6 +386,14 @@ module.exports = React.createClass({
</AccessibleButton>; </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; let rightPanelButtons;
if (this.props.collapsedRhs) { if (this.props.collapsedRhs) {
rightPanelButtons = rightPanelButtons =
@ -400,6 +415,7 @@ module.exports = React.createClass({
<div className="mx_RoomHeader_rightRow"> <div className="mx_RoomHeader_rightRow">
{ settingsButton } { settingsButton }
{ pinnedEventsButton } { pinnedEventsButton }
{ shareRoomButton }
{ manageIntegsButton } { manageIntegsButton }
{ forgetButton } { forgetButton }
{ searchButton } { searchButton }

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