Merge branches 'develop' and 't3chguy/edit_skip_if_no_edit' of github.com:matrix-org/matrix-react-sdk into t3chguy/edit_skip_if_no_edit

This commit is contained in:
Michael Telatynski 2019-06-29 06:45:06 +01:00
commit bf9353f3af
122 changed files with 3020 additions and 1327 deletions

View file

@ -47,7 +47,6 @@ src/components/views/rooms/UserTile.js
src/components/views/settings/ChangeAvatar.js
src/components/views/settings/ChangePassword.js
src/components/views/settings/DevicesPanel.js
src/components/views/settings/IntegrationsManager.js
src/components/views/settings/Notifications.js
src/GroupAddressPicker.js
src/HtmlUtils.js

View file

@ -1,3 +1,207 @@
Changes in [1.2.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.2) (2019-06-19)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.2-rc.2...v1.2.2)
No changes since rc.2
Changes in [1.2.2-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.2-rc.2) (2019-06-18)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.2-rc.1...v1.2.2-rc.2)
* Defer scalar API calls until they are needed
[\#3115](https://github.com/matrix-org/matrix-react-sdk/pull/3115)
* Blend pending redactions
[\#3117](https://github.com/matrix-org/matrix-react-sdk/pull/3117)
* Keep old arrow-up behaviour when editing is not enabled
[\#3116](https://github.com/matrix-org/matrix-react-sdk/pull/3116)
* Restore Composer History under shift-up & down
[\#3098](https://github.com/matrix-org/matrix-react-sdk/pull/3098)
* Allow changing server if validation has failed
[\#3114](https://github.com/matrix-org/matrix-react-sdk/pull/3114)
* Add Upload All button to UploadConfirmDialog
[\#3109](https://github.com/matrix-org/matrix-react-sdk/pull/3109)
* Re-enable register button
[\#3112](https://github.com/matrix-org/matrix-react-sdk/pull/3112)
* keep mx_Field stretching
[\#3111](https://github.com/matrix-org/matrix-react-sdk/pull/3111)
* Fix double-spinner
[\#3107](https://github.com/matrix-org/matrix-react-sdk/pull/3107)
* Fix display of canonicalAlias in group room info
[\#3110](https://github.com/matrix-org/matrix-react-sdk/pull/3110)
* Fix welcome user
[\#3106](https://github.com/matrix-org/matrix-react-sdk/pull/3106)
* Support editing emote messages
[\#3105](https://github.com/matrix-org/matrix-react-sdk/pull/3105)
* Use flex: 1 for mx_Field to replace all the calc(100% - 20px) and more
[\#3104](https://github.com/matrix-org/matrix-react-sdk/pull/3104)
* Use overflow on MemberInfo name/mxid so that the back button stays
[\#3099](https://github.com/matrix-org/matrix-react-sdk/pull/3099)
* Allow changing servers on nonfatal errors
[\#3102](https://github.com/matrix-org/matrix-react-sdk/pull/3102)
* Simplify email registration
[\#3101](https://github.com/matrix-org/matrix-react-sdk/pull/3101)
* Allow arrow keys navigation in autocomplete list
[\#2966](https://github.com/matrix-org/matrix-react-sdk/pull/2966)
* Edit unsent messages
[\#3097](https://github.com/matrix-org/matrix-react-sdk/pull/3097)
* Fix registration with email + non-default HS
[\#3096](https://github.com/matrix-org/matrix-react-sdk/pull/3096)
* Raise action bar above read marker
[\#3095](https://github.com/matrix-org/matrix-react-sdk/pull/3095)
* Console log more helpfully
[\#3094](https://github.com/matrix-org/matrix-react-sdk/pull/3094)
Changes in [1.2.2-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.2-rc.1) (2019-06-12)
=============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.1...v1.2.2-rc.1)
* Align message context menu to right and vertically where space available
[\#3087](https://github.com/matrix-org/matrix-react-sdk/pull/3087)
* Allow registration to submit for non-fatal errors
[\#3093](https://github.com/matrix-org/matrix-react-sdk/pull/3093)
* Clear the login busy state after .well-known discovery
[\#3092](https://github.com/matrix-org/matrix-react-sdk/pull/3092)
* Update from Weblate
[\#3091](https://github.com/matrix-org/matrix-react-sdk/pull/3091)
* Fix registration after fail-fast
[\#3090](https://github.com/matrix-org/matrix-react-sdk/pull/3090)
* Use setBusy interface of js-sdk interactive auth
[\#3085](https://github.com/matrix-org/matrix-react-sdk/pull/3085)
* Don't handle identity server failure as fatal, and use the right message
[\#3088](https://github.com/matrix-org/matrix-react-sdk/pull/3088)
* Recheck message actions on decrypt
[\#3084](https://github.com/matrix-org/matrix-react-sdk/pull/3084)
* Fix exception on logout
[\#3086](https://github.com/matrix-org/matrix-react-sdk/pull/3086)
* Remember we were trying to accept an invite
[\#3083](https://github.com/matrix-org/matrix-react-sdk/pull/3083)
* Add funding details for GitHub sponsor button
[\#3079](https://github.com/matrix-org/matrix-react-sdk/pull/3079)
* Remove highlight from reactions
[\#3081](https://github.com/matrix-org/matrix-react-sdk/pull/3081)
* Clarify that only lowercase letters are allowed
[\#3080](https://github.com/matrix-org/matrix-react-sdk/pull/3080)
* Don't handle identity server liveliness errors as fatal
[\#3082](https://github.com/matrix-org/matrix-react-sdk/pull/3082)
* truncate long display names in timeline headings
[\#3078](https://github.com/matrix-org/matrix-react-sdk/pull/3078)
* Fail more softly on homeserver liveliness errors
[\#3067](https://github.com/matrix-org/matrix-react-sdk/pull/3067)
* Fix AddressPickerDialog adding wrong entry to selected list case
[\#3076](https://github.com/matrix-org/matrix-react-sdk/pull/3076)
* change profile keybind to backtick from i due to italics conflict
[\#3077](https://github.com/matrix-org/matrix-react-sdk/pull/3077)
* Look busy whilst requesting the email token
[\#3075](https://github.com/matrix-org/matrix-react-sdk/pull/3075)
* Fix email invites address-match checking
[\#3074](https://github.com/matrix-org/matrix-react-sdk/pull/3074)
* Add license info for Twemoji
[\#3073](https://github.com/matrix-org/matrix-react-sdk/pull/3073)
* Show read receipts on top of message
[\#3072](https://github.com/matrix-org/matrix-react-sdk/pull/3072)
* Be somewhat fuzzier when matching emojis to complete on space
[\#3070](https://github.com/matrix-org/matrix-react-sdk/pull/3070)
* Restrict reactions to a single emoji
[\#3069](https://github.com/matrix-org/matrix-react-sdk/pull/3069)
* Fix live updates to reaction row buttons
[\#3068](https://github.com/matrix-org/matrix-react-sdk/pull/3068)
* Don't refresh custom status on logout
[\#3065](https://github.com/matrix-org/matrix-react-sdk/pull/3065)
* Add a logged in class to EmbeddedPage and react to MatrixClient changes
[\#3066](https://github.com/matrix-org/matrix-react-sdk/pull/3066)
* Don't show "can't redact" dialog on network error, with redaction having
local echo & queuing now.
[\#3058](https://github.com/matrix-org/matrix-react-sdk/pull/3058)
* Fix login page breaking on wrong password
[\#3062](https://github.com/matrix-org/matrix-react-sdk/pull/3062)
* Update from Weblate
[\#3064](https://github.com/matrix-org/matrix-react-sdk/pull/3064)
* Install latest JS SDK when linting
[\#3063](https://github.com/matrix-org/matrix-react-sdk/pull/3063)
* Ensure we always show read receipts even with hidden events
[\#3056](https://github.com/matrix-org/matrix-react-sdk/pull/3056)
* Advance read receipts into trailing events without tiles
[\#3059](https://github.com/matrix-org/matrix-react-sdk/pull/3059)
* Remove unused errorText prop
[\#3061](https://github.com/matrix-org/matrix-react-sdk/pull/3061)
* Remove SettingsStore reference in RoomSettingsDialog
[\#3060](https://github.com/matrix-org/matrix-react-sdk/pull/3060)
* Custom notification sounds for rooms
[\#2928](https://github.com/matrix-org/matrix-react-sdk/pull/2928)
* Fix comments in unread room tracking
[\#3054](https://github.com/matrix-org/matrix-react-sdk/pull/3054)
* Allow source tile handler for replacements
[\#3057](https://github.com/matrix-org/matrix-react-sdk/pull/3057)
* Fix linting in MessagePanel
[\#3055](https://github.com/matrix-org/matrix-react-sdk/pull/3055)
* Convert breadcrumbs from labs to real setting
[\#3053](https://github.com/matrix-org/matrix-react-sdk/pull/3053)
* Add local echo on badges in breadcrumbs
[\#3052](https://github.com/matrix-org/matrix-react-sdk/pull/3052)
* Counteract smooth scrolling on breadcrumbs
[\#3051](https://github.com/matrix-org/matrix-react-sdk/pull/3051)
* add sbix fallback twemoji font (and bump to emoji 12)
[\#3050](https://github.com/matrix-org/matrix-react-sdk/pull/3050)
* Add option to change the default country code
[\#3049](https://github.com/matrix-org/matrix-react-sdk/pull/3049)
* Accept JSX into the GenericErrorPage and expose local session vars
[\#3043](https://github.com/matrix-org/matrix-react-sdk/pull/3043)
* Don't try and low encryption info when signing out in low bandwidth mode
[\#3048](https://github.com/matrix-org/matrix-react-sdk/pull/3048)
* only capture enter if something was selected in completions
[\#3047](https://github.com/matrix-org/matrix-react-sdk/pull/3047)
* Fix: better HTML > MD conversion for editing, including lists and quotes
[\#3040](https://github.com/matrix-org/matrix-react-sdk/pull/3040)
* Native emoji require extra line-height
[\#3044](https://github.com/matrix-org/matrix-react-sdk/pull/3044)
* port over low_bandwidth mode to develop
[\#2598](https://github.com/matrix-org/matrix-react-sdk/pull/2598)
* Fix: maintain caret at current line when position is on newline part
[\#3029](https://github.com/matrix-org/matrix-react-sdk/pull/3029)
* Remove username on HS input label
[\#3042](https://github.com/matrix-org/matrix-react-sdk/pull/3042)
* Exclude chrome in ua from safari version check for colr support
[\#3038](https://github.com/matrix-org/matrix-react-sdk/pull/3038)
* fix COLR font check being racy
[\#3034](https://github.com/matrix-org/matrix-react-sdk/pull/3034)
* Override font for usercontent download link
[\#3035](https://github.com/matrix-org/matrix-react-sdk/pull/3035)
* Revert "Make the timeline less noisy for screen readers (mk II) #3019"
[\#3033](https://github.com/matrix-org/matrix-react-sdk/pull/3033)
* Hide autocomplete on Enter key press instead of sending message
[\#2968](https://github.com/matrix-org/matrix-react-sdk/pull/2968)
* Message editing: arrow key (up/down) navigation between editable events
[\#3025](https://github.com/matrix-org/matrix-react-sdk/pull/3025)
* Message editing: fix reply text appearing in edit
[\#3032](https://github.com/matrix-org/matrix-react-sdk/pull/3032)
* Do not try to request thumbnails with non-integer widths
[\#3031](https://github.com/matrix-org/matrix-react-sdk/pull/3031)
* Message editing: preserve strikethrough as well
[\#3030](https://github.com/matrix-org/matrix-react-sdk/pull/3030)
* Add some logging for COLR checks
[\#3027](https://github.com/matrix-org/matrix-react-sdk/pull/3027)
* Fixup for tab completion: take part length into account as well
[\#3026](https://github.com/matrix-org/matrix-react-sdk/pull/3026)
* Message editing: tab completion
[\#3024](https://github.com/matrix-org/matrix-react-sdk/pull/3024)
* Message editing: dont jump to next part when inserting at *start* of
uneditable part
[\#3021](https://github.com/matrix-org/matrix-react-sdk/pull/3021)
* Message editing: preserve and re-apply formatting
[\#3013](https://github.com/matrix-org/matrix-react-sdk/pull/3013)
* Fix relationship between guests, .well-known, and auth
[\#3001](https://github.com/matrix-org/matrix-react-sdk/pull/3001)
* Restore use of full mxid login
[\#2972](https://github.com/matrix-org/matrix-react-sdk/pull/2972)
* Only expose the fallback_hs_url if the homeserver is the default homeserver
[\#2971](https://github.com/matrix-org/matrix-react-sdk/pull/2971)
* Refactor "Next" button into ServerConfig components
[\#2964](https://github.com/matrix-org/matrix-react-sdk/pull/2964)
* Render underlines and tooltips on custom server names in auth pages
[\#2965](https://github.com/matrix-org/matrix-react-sdk/pull/2965)
* Use validated server config for login, registration, and password reset
[\#2941](https://github.com/matrix-org/matrix-react-sdk/pull/2941)
Changes in [1.2.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v1.2.1) (2019-05-31)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v1.2.0...v1.2.1)

View file

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "1.2.1",
"version": "1.2.2",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -75,13 +75,13 @@
"gemini-scrollbar": "github:matrix-org/gemini-scrollbar#b302279",
"gfm.css": "^1.1.1",
"glob": "^5.0.14",
"highlight.js": "9.14.2",
"highlight.js": "^9.15.8",
"is-ip": "^2.0.0",
"isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.6",
"lodash": "^4.13.1",
"lolex": "2.3.2",
"matrix-js-sdk": "2.0.0",
"matrix-js-sdk": "2.0.1",
"optimist": "^0.6.1",
"pako": "^1.0.5",
"png-chunks-extract": "^1.0.0",

View file

@ -50,7 +50,6 @@
@import "./views/context_menus/_TopLeftMenu.scss";
@import "./views/dialogs/_AddressPickerDialog.scss";
@import "./views/dialogs/_Analytics.scss";
@import "./views/dialogs/_BugReportDialog.scss";
@import "./views/dialogs/_ChangelogDialog.scss";
@import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss";
@import "./views/dialogs/_ConfirmUserActionDialog.scss";
@ -62,6 +61,7 @@
@import "./views/dialogs/_EncryptedEventDialog.scss";
@import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_IncomingSasDialog.scss";
@import "./views/dialogs/_MessageEditHistoryDialog.scss";
@import "./views/dialogs/_RestoreKeyBackupDialog.scss";
@import "./views/dialogs/_RoomSettingsDialog.scss";
@import "./views/dialogs/_RoomUpgradeDialog.scss";
@ -87,6 +87,7 @@
@import "./views/elements/_Field.scss";
@import "./views/elements/_ImageView.scss";
@import "./views/elements/_InlineSpinner.scss";
@import "./views/elements/_InteractiveTooltip.scss";
@import "./views/elements/_ManageIntegsButton.scss";
@import "./views/elements/_MemberEventListSummary.scss";
@import "./views/elements/_MessageEditor.scss";
@ -100,8 +101,8 @@
@import "./views/elements/_SyntaxHighlight.scss";
@import "./views/elements/_TextWithTooltip.scss";
@import "./views/elements/_ToggleSwitch.scss";
@import "./views/elements/_ToolTipButton.scss";
@import "./views/elements/_Tooltip.scss";
@import "./views/elements/_TooltipButton.scss";
@import "./views/elements/_Validation.scss";
@import "./views/globals/_MatrixToolbar.scss";
@import "./views/groups/_GroupPublicityToggle.scss";
@ -117,7 +118,8 @@
@import "./views/messages/_MTextBody.scss";
@import "./views/messages/_MessageActionBar.scss";
@import "./views/messages/_MessageTimestamp.scss";
@import "./views/messages/_ReactionDimension.scss";
@import "./views/messages/_ReactionQuickTooltip.scss";
@import "./views/messages/_ReactionTooltipButton.scss";
@import "./views/messages/_ReactionsRow.scss";
@import "./views/messages/_ReactionsRowButton.scss";
@import "./views/messages/_ReactionsRowButtonTooltip.scss";

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -58,18 +59,6 @@ limitations under the License.
border-bottom: 8px solid transparent;
}
.mx_ContextualMenu_chevron_right::after {
content: '';
width: 0;
height: 0;
border-top: 7px solid transparent;
border-left: 7px solid $menu-bg-color;
border-bottom: 7px solid transparent;
position: absolute;
top: -7px;
right: 1px;
}
.mx_ContextualMenu_left {
left: 0;
}
@ -89,18 +78,6 @@ limitations under the License.
border-bottom: 8px solid transparent;
}
.mx_ContextualMenu_chevron_left::after {
content: '';
width: 0;
height: 0;
border-top: 7px solid transparent;
border-right: 7px solid $menu-bg-color;
border-bottom: 7px solid transparent;
position: absolute;
top: -7px;
left: 1px;
}
.mx_ContextualMenu_top {
top: 0;
}
@ -120,18 +97,6 @@ limitations under the License.
border-right: 8px solid transparent;
}
.mx_ContextualMenu_chevron_top::after {
content: '';
width: 0;
height: 0;
border-left: 7px solid transparent;
border-bottom: 7px solid $menu-bg-color;
border-right: 7px solid transparent;
position: absolute;
left: -7px;
top: 1px;
}
.mx_ContextualMenu_bottom {
bottom: 0;
}
@ -151,24 +116,6 @@ limitations under the License.
border-right: 8px solid transparent;
}
.mx_ContextualMenu_chevron_bottom::after {
content: '';
width: 0;
height: 0;
border-left: 7px solid transparent;
border-top: 7px solid $menu-bg-color;
border-right: 7px solid transparent;
position: absolute;
left: -7px;
bottom: 1px;
}
.mx_ContextualMenu_field {
padding: 3px 6px 3px 6px;
cursor: pointer;
white-space: nowrap;
}
.mx_ContextualMenu_spinner {
display: block;
margin: 0 auto;

View file

@ -2,17 +2,15 @@
width: 100%;
height: 100%;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.mx_GenericErrorPage_box {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
display: inline;
width: 500px;
height: 125px;
min-height: 125px;
border: 1px solid #f22;
padding: 10px 10px 20px;
background-color: #fcc;

View file

@ -35,13 +35,6 @@ limitations under the License.
flex: 1;
}
.mx_RoomDirectory .gm-scroll-view {
// little hack because gemini doesn't seem to detect
// the scrollbar width well in this instance
// when using css scrollbars
scrollbar-width: thin;
}
.mx_RoomDirectory_createRoom {
background-color: $button-bg-color;
border-radius: 4px;

View file

@ -63,7 +63,6 @@ limitations under the License.
display: flex;
flex-direction: column;
align-items: center;
margin-top: 5px;
height: 100%;
}

View file

@ -30,6 +30,7 @@ limitations under the License.
.mx_Login_submit:disabled {
opacity: 0.3;
cursor: default;
}
.mx_AuthBody a.mx_Login_sso_link:link,

View file

@ -72,7 +72,6 @@ limitations under the License.
}
.mx_Field input {
width: 100%;
box-sizing: border-box;
}
@ -110,7 +109,6 @@ limitations under the License.
.mx_AuthBody_fieldRow > .mx_Field {
margin: 0 5px;
flex: 1;
}
.mx_AuthBody_fieldRow > .mx_Field:first-child {

View file

@ -49,10 +49,14 @@ limitations under the License.
}
.mx_InteractiveAuthEntryComponents_termsSubmit:disabled {
background-color: $accent-color-50pct;
background-color: $accent-color-darker;
cursor: default;
}
.mx_InteractiveAuthEntryComponents_termsPolicy {
display: block;
}
}
.mx_InteractiveAuthEntryComponents_passwordSection {
width: 300px;
}

View file

@ -20,7 +20,6 @@ limitations under the License.
}
.mx_ServerConfig_fields .mx_Field {
flex: 1;
margin: 0 5px;
}

View file

@ -21,3 +21,7 @@ limitations under the License.
.mx_DeactivateAccountDialog .mx_DeactivateAccountDialog_input_section {
margin-top: 60px;
}
.mx_DeactivateAccountDialog .mx_DeactivateAccountDialog_input_section .mx_Field {
width: 300px;
}

View file

@ -23,7 +23,11 @@ limitations under the License.
cursor: default !important;
}
.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button, .mx_DevTools_RoomStateExplorer_query {
.mx_DevTools_RoomStateExplorer_query {
margin-bottom: 10px;
}
.mx_DevTools_RoomStateExplorer_button, .mx_DevTools_ServersInRoomList_button {
margin-bottom: 10px;
width: 100%;
}
@ -75,11 +79,6 @@ limitations under the License.
max-width: 684px;
min-height: 250px;
padding: 10px;
width: 100%;
}
.mx_DevTools_content .mx_Field_input {
display: inline-block;
}
.mx_DevTools_eventTypeStateKeyGroup {

View file

@ -0,0 +1,41 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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_MessageEditHistoryDialog .mx_Dialog_header > .mx_Dialog_title {
text-align: center;
}
.mx_MessageEditHistoryDialog {
display: flex;
flex-direction: column;
max-height: 60vh;
}
.mx_MessageEditHistoryDialog_scrollPanel {
flex: 1 1 auto;
}
.mx_MessageEditHistoryDialog_edits {
list-style-type: none;
font-size: 14px;
padding: 0;
color: $primary-fg-color;
.mx_EventTile_line, .mx_EventTile_content {
margin-right: 0px;
}
}

View file

@ -21,7 +21,6 @@ limitations under the License.
color: $primary-fg-color;
background-color: $primary-bg-color;
font-size: 15px;
width: 100%;
max-width: 280px;
margin-bottom: 10px;
}

View file

@ -42,12 +42,6 @@ limitations under the License.
margin-right: 5px;
}
.mx_EditableItemList_newItem .mx_Field input {
// Use 100% of the space available for the input, but don't let the 10px
// padding on either side of the input to push it out of alignment.
width: calc(100% - 20px);
}
.mx_EditableItemList_label {
margin-bottom: 5px;
}
}

View file

@ -18,6 +18,8 @@ limitations under the License.
.mx_Field {
display: flex;
flex: 1;
min-width: 0;
position: relative;
margin: 1em 0;
border-radius: 4px;
@ -42,6 +44,7 @@ limitations under the License.
padding: 8px 9px;
color: $primary-fg-color;
background-color: $primary-bg-color;
flex: 1;
}
.mx_Field select {

View file

@ -0,0 +1,101 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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_InteractiveTooltip_wrapper {
position: fixed;
z-index: 5000;
}
.mx_InteractiveTooltip_background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 1.0;
z-index: 5000;
}
.mx_InteractiveTooltip {
border-radius: 3px;
background-color: $interactive-tooltip-bg-color;
color: $interactive-tooltip-fg-color;
position: absolute;
font-size: 10px;
font-weight: 600;
padding: 6px;
z-index: 5001;
}
.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_top {
top: 10px; // 8px chevron + 2px spacing
}
.mx_InteractiveTooltip_chevron_top {
position: absolute;
left: calc(50% - 8px);
top: -8px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-bottom: 8px solid $interactive-tooltip-bg-color;
border-right: 8px solid transparent;
}
// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path
// by Sebastiano Guerriero (@guerriero_se)
@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) {
.mx_InteractiveTooltip_chevron_top {
height: 16px;
width: 16px;
background-color: inherit;
border: none;
clip-path: polygon(0% 0%, 100% 100%, 0% 100%);
transform: rotate(135deg);
border-radius: 0 0 0 3px;
top: calc(-8px / 1.414); // sqrt(2) because of rotation
}
}
.mx_InteractiveTooltip.mx_InteractiveTooltip_withChevron_bottom {
bottom: 10px; // 8px chevron + 2px spacing
}
.mx_InteractiveTooltip_chevron_bottom {
position: absolute;
left: calc(50% - 8px);
bottom: -8px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-top: 8px solid $interactive-tooltip-bg-color;
border-right: 8px solid transparent;
}
// Adapted from https://codyhouse.co/blog/post/css-rounded-triangles-with-clip-path
// by Sebastiano Guerriero (@guerriero_se)
@supports (clip-path: polygon(0% 0%, 100% 100%, 0% 100%)) {
.mx_InteractiveTooltip_chevron_bottom {
height: 16px;
width: 16px;
background-color: inherit;
border: none;
clip-path: polygon(0% 0%, 100% 100%, 0% 100%);
transform: rotate(-45deg);
border-radius: 0 0 0 3px;
bottom: calc(-8px / 1.414); // sqrt(2) because of rotation
}
}

View file

@ -34,6 +34,10 @@ limitations under the License.
max-height: 200px;
overflow-x: auto;
&:focus {
border-color: $accent-color-50pct;
}
span.mx_UserPill, span.mx_RoomPill {
padding-left: 21px;
position: relative;

View file

@ -20,6 +20,5 @@ limitations under the License.
.mx_PowerSelector .mx_Field select,
.mx_PowerSelector .mx_Field input {
width: 100%;
box-sizing: border-box;
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ToolTipButton {
.mx_TooltipButton {
display: inline-block;
width: 11px;
height: 11px;
@ -33,17 +34,17 @@ limitations under the License.
cursor: pointer;
}
.mx_ToolTipButton:hover {
.mx_TooltipButton:hover {
opacity: 1.0;
}
.mx_ToolTipButton_container {
.mx_TooltipButton_container {
position: relative;
top: -18px;
left: 4px;
}
.mx_ToolTipButton_helpText {
.mx_TooltipButton_helpText {
width: 400px;
text-align: start;
line-height: 17px !important;

View file

@ -67,6 +67,10 @@ limitations under the License.
background-color: $message-action-bar-fg-color;
}
.mx_MessageActionBar_reactButton::after {
mask-image: url('$(res)/img/react.svg');
}
.mx_MessageActionBar_replyButton::after {
mask-image: url('$(res)/img/reply.svg');
}

View file

@ -15,4 +15,6 @@ limitations under the License.
*/
.mx_MessageTimestamp {
color: $event-timestamp-color;
font-size: 10px;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2017 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,12 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_BugReportDialog .mx_Field {
flex: 1;
.mx_ReactionsQuickTooltip_buttons {
display: grid;
grid-template-columns: repeat(4, auto);
}
.mx_BugReportDialog_field_input {
// TODO: We should really apply this to all .mx_Field inputs.
// See https://github.com/vector-im/riot-web/issues/9344.
flex: 1;
.mx_ReactionsQuickTooltip_label {
text-align: center;
}
.mx_ReactionsQuickTooltip_shortcode {
padding-left: 6px;
opacity: 0.7;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,12 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_ReactionDimension {
width: 42px;
display: flex;
justify-content: space-evenly;
.mx_ReactionTooltipButton {
font-size: 16px;
padding: 6px;
user-select: none;
cursor: pointer;
transition: transform 0.25s;
&:hover {
transform: scale(1.2);
}
}
.mx_ReactionDimension_disabled {
.mx_ReactionTooltipButton_selected {
opacity: 0.4;
}

View file

@ -18,3 +18,17 @@ limitations under the License.
margin: 6px 0;
color: $primary-fg-color;
}
.mx_ReactionsRow_showAll {
text-decoration: none;
font-size: 10px;
font-weight: 600;
margin-left: 6px;
vertical-align: top;
&:hover,
&:link,
&:visited {
color: $accent-color;
}
}

View file

@ -93,8 +93,6 @@ limitations under the License.
display: block;
visibility: hidden;
white-space: nowrap;
color: $event-timestamp-color;
font-size: 10px;
left: 0px;
width: 46px; /* 8 + 30 (avatar) + 8 */
text-align: center;
@ -169,6 +167,9 @@ limitations under the License.
.mx_EventTile_sending .mx_RoomPill {
opacity: 0.5;
}
.mx_EventTile_sending.mx_EventTile_redacted .mx_UnknownBody {
opacity: 0.4;
}
.mx_EventTile_notSent {
color: $event-notsent-color;
@ -400,6 +401,7 @@ limitations under the License.
color: $roomtopic-color;
display: inline-block;
margin-left: 9px;
cursor: pointer;
}
/* Various markdown overrides */

View file

@ -43,6 +43,8 @@ limitations under the License.
.mx_MemberInfo_name h2 {
flex: 1;
overflow-x: auto;
max-height: 50px;
}
.mx_MemberInfo h2 {

View file

@ -39,6 +39,16 @@ limitations under the License.
margin: 10px 10px 10px 0;
flex: 0 0 auto;
}
.mx_RoomPreviewBar_footer {
font-size: 12px;
line-height: 20px;
.mx_Spinner {
vertical-align: middle;
display: inline-block;
}
}
}
.mx_RoomPreviewBar_dark {

View file

@ -15,17 +15,22 @@ limitations under the License.
*/
.mx_RoomUpgradeWarningBar {
max-height: 235px;
background-color: $preview-bar-bg-color;
padding-left: 20px;
padding-right: 20px;
overflow: scroll;
}
.mx_RoomUpgradeWarningBar_wrapped {
width: 100%;
height: 100%;
display: flex;
text-align: center;
height: 235px;
background-color: $event-selected-color;
align-items: center;
flex-direction: column;
justify-content: center;
display: flex;
background-color: $preview-bar-bg-color;
-webkit-align-items: center;
padding-left: 20px;
padding-right: 20px;
}
.mx_RoomUpgradeWarningBar_header {

View file

@ -35,9 +35,3 @@ limitations under the License.
.mx_ExistingEmailAddress_confirmBtn {
margin-right: 5px;
}
.mx_EmailAddresses_new .mx_Field input {
// Use 100% of the space available for the input, but don't let the 10px
// padding on either side of the input to push it out of alignment.
width: calc(100% - 20px);
}

View file

@ -29,3 +29,16 @@ limitations under the License.
width: 100%;
height: 100%;
}
.mx_IntegrationsManager_loading h3 {
text-align: center;
}
.mx_IntegrationsManager_error {
text-align: center;
padding-top: 20px;
}
.mx_IntegrationsManager_error h3 {
color: $warning-color;
}

View file

@ -36,12 +36,6 @@ limitations under the License.
margin-right: 5px;
}
.mx_PhoneNumbers_new .mx_Field input {
// Use 100% of the space available for the input, but don't let the 10px
// padding on either side of the input to push it out of alignment.
width: calc(100% - 20px);
}
.mx_PhoneNumbers_input {
display: flex;
align-items: center;

View file

@ -22,11 +22,6 @@ limitations under the License.
flex-grow: 1;
}
.mx_ProfileSettings_controls .mx_Field #profileDisplayName,
.mx_ProfileSettings_controls .mx_Field #profileTopic {
width: calc(100% - 20px); // subtract 10px padding on left and right
}
.mx_ProfileSettings_controls .mx_Field #profileTopic {
height: 4em;
}

View file

@ -17,7 +17,3 @@ limitations under the License.
.mx_GeneralRoomSettingsTab_profileSection {
margin-top: 10px;
}
.mx_GeneralRoomSettingsTab .mx_AliasSettings .mx_Field select {
width: 100%;
}

View file

@ -14,33 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_GeneralUserSettingsTab_changePassword,
.mx_GeneralUserSettingsTab_themeSection {
display: block;
}
.mx_GeneralUserSettingsTab_changePassword .mx_Field,
.mx_GeneralUserSettingsTab_themeSection .mx_Field {
display: block;
margin-right: 100px; // Align with the other fields on the page
}
.mx_GeneralUserSettingsTab_changePassword .mx_Field input {
display: block;
width: calc(100% - 20px); // subtract 10px padding on left and right
}
.mx_GeneralUserSettingsTab_changePassword .mx_Field:first-child {
margin-top: 0;
}
.mx_GeneralUserSettingsTab_themeSection .mx_Field select {
display: block;
width: 100%;
}
.mx_GeneralUserSettingsTab_accountSection > .mx_EmailAddresses,
.mx_GeneralUserSettingsTab_accountSection > .mx_PhoneNumbers,
.mx_GeneralUserSettingsTab_languageInput {
margin-right: 100px; // Align with the other fields on the page
}
}

View file

@ -17,11 +17,3 @@ limitations under the License.
.mx_PreferencesUserSettingsTab .mx_Field {
margin-right: 100px; // Align with the rest of the controls
}
.mx_PreferencesUserSettingsTab .mx_Field input {
display: block;
// Subtract 10px padding on left and right
// This is to keep the input aligned with the rest of the tab's controls.
width: calc(100% - 20px);
}

View file

@ -14,11 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_VoiceUserSettingsTab .mx_Field select {
width: 100%;
max-width: 100%;
}
.mx_VoiceUserSettingsTab .mx_Field {
margin-right: 100px; // align with the rest of the fields
}

10
res/img/react.svg Normal file
View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="17" viewBox="0 0 18 17">
<g fill="none" fill-rule="evenodd">
<path fill="#2E2F32" fill-rule="nonzero" d="M13.6 14.5a7.875 7.875 0 1 1 2.183-3h-.818a7.125 7.125 0 1 0-2.62 3H13.6z"/>
<g stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round" stroke-width=".75">
<path d="M11.5 13h6M14.5 10v6"/>
</g>
<path stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round" stroke-width=".75" d="M5.5 10s1.125 1.5 3 1.5 3-1.5 3-1.5"/>
<path stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.25 6.25h.008M10.75 6.25h.008"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 696 B

View file

@ -160,6 +160,9 @@ $reaction-row-button-selected-border-color: $accent-color;
$tooltip-timeline-bg-color: $tagpanel-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: $base-color;
$interactive-tooltip-fg-color: #ffffff;
// ***** Mixins! *****
@define-mixin mx_DialogButton {

View file

@ -28,7 +28,8 @@ $focus-bg-color: #dddddd;
// button UI (white-on-green in light skin)
$accent-fg-color: #ffffff;
$accent-color-50pct: #92caad;
$accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb
$accent-color-darker: #92caad;
$accent-color-alt: #238CF5;
$selection-fg-color: $primary-bg-color;
@ -272,6 +273,9 @@ $reaction-row-button-selected-border-color: $accent-color;
$tooltip-timeline-bg-color: $tagpanel-bg-color;
$tooltip-timeline-fg-color: #ffffff;
$interactive-tooltip-bg-color: #27303a;
$interactive-tooltip-fg-color: #ffffff;
// ***** Mixins! *****
@define-mixin mx_DialogButton {

View file

@ -344,7 +344,7 @@ function _onAction(payload) {
}
async function _startCallApp(roomId, type) {
// check for a working intgrations manager. Technically we could put
// check for a working integrations manager. Technically we could put
// the state event in anyway, but the resulting widget would then not
// work for us. Better that the user knows before everyone else in the
// room sees it.

View file

@ -18,6 +18,11 @@ import * as Matrix from 'matrix-js-sdk';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
export default {
hasAnyLabeledDevices: async function() {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some(d => !!d.label);
},
getDevices: function() {
// Only needed for Electron atm, though should work in modern browsers
// once permission has been granted to the webapp
@ -26,8 +31,6 @@ export default {
const audioinput = [];
const videoinput = [];
if (devices.some((device) => !device.label)) return false;
devices.forEach((device) => {
switch (device.kind) {
case 'audiooutput': audiooutput.push(device); break;

View file

@ -0,0 +1,86 @@
//@flow
/*
Copyright 2017 Aviral Dasgupta
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 {Value} from 'slate';
import _clamp from 'lodash/clamp';
type MessageFormat = 'rich' | 'markdown';
class HistoryItem {
// We store history items in their native format to ensure history is accurate
// and then convert them if our RTE has subsequently changed format.
value: Value;
format: MessageFormat = 'rich';
constructor(value: ?Value, format: ?MessageFormat) {
this.value = value;
this.format = format;
}
static fromJSON(obj: Object): HistoryItem {
return new HistoryItem(
Value.fromJSON(obj.value),
obj.format,
);
}
toJSON(): Object {
return {
value: this.value.toJSON(),
format: this.format,
};
}
}
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;
// TODO: Performance issues?
let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
try {
this.history.push(
HistoryItem.fromJSON(JSON.parse(item)),
);
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
}
this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
}
save(value: Value, format: MessageFormat) {
const item = new HistoryItem(value, format);
this.history.push(item);
this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
}
getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

View file

@ -425,19 +425,25 @@ export default class ContentMessages {
}
const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog");
let uploadAll = false;
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
const shouldContinue = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
onFinished: (shouldContinue) => {
resolve(shouldContinue);
},
if (!uploadAll) {
const shouldContinue = await new Promise((resolve) => {
Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, {
file,
currentIndex: i,
totalFiles: okFiles.length,
onFinished: (shouldContinue, shouldUploadAll) => {
if (shouldUploadAll) {
uploadAll = true;
}
resolve(shouldContinue);
},
});
});
});
if (!shouldContinue) break;
if (!shouldContinue) break;
}
this._sendContentToRoom(file, roomId, matrixClient);
}
}

View file

@ -17,9 +17,12 @@ limitations under the License.
import URL from 'url';
import dis from './dispatcher';
import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import sdk from "./index";
import Modal from "./Modal";
import MatrixClientPeg from "./MatrixClientPeg";
import RoomViewStore from "./stores/RoomViewStore";
const WIDGET_API_VERSION = '0.0.2'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
@ -189,7 +192,14 @@ export default class FromWidgetPostMessageApi {
const data = event.data.data || event.data.widgetData;
const integType = (data && data.integType) ? data.integType : null;
const integId = (data && data.integId) ? data.integId : null;
IntegrationManager.open(integType, integId);
// The dialog will take care of scalar auth for us
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
room: MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
screen: 'type_' + integType,
integrationId: integId,
}, "mx_IntegrationsManager");
} else if (action === 'set_always_on_screen') {
// This is a new message: there is no reason to support the deprecated widgetData here
const data = event.data.data;

View file

@ -1,78 +0,0 @@
/*
Copyright 2017 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 Modal from './Modal';
import sdk from './index';
import SdkConfig from './SdkConfig';
import ScalarMessaging from './ScalarMessaging';
import ScalarAuthClient from './ScalarAuthClient';
import RoomViewStore from './stores/RoomViewStore';
if (!global.mxIntegrationManager) {
global.mxIntegrationManager = {};
}
export default class IntegrationManager {
static _init() {
if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) {
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
ScalarMessaging.startListening();
global.mxIntegrationManager.client = new ScalarAuthClient();
return global.mxIntegrationManager.client.connect().then(() => {
global.mxIntegrationManager.connected = true;
}).catch((e) => {
console.error("Failed to connect to integrations server", e);
global.mxIntegrationManager.error = e;
});
} else {
console.error('Invalid integration manager config', SdkConfig.get());
}
}
}
/**
* Launch the integrations manager on the stickers integration page
* @param {string} integName integration / widget type
* @param {string} integId integration / widget ID
* @param {function} onFinished Callback to invoke on integration manager close
*/
static async open(integName, integId, onFinished) {
await IntegrationManager._init();
if (global.mxIntegrationManager.client) {
await global.mxIntegrationManager.client.connect();
} else {
return;
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
if (global.mxIntegrationManager.error ||
!(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) {
console.error("Scalar error", global.mxIntegrationManager);
return;
}
const integType = 'type_' + integName;
const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ?
global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom(
{roomId: RoomViewStore.getRoomId()},
integType,
integId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
onFinished: onFinished,
}, "mx_IntegrationsManager");
}
}

View file

@ -51,6 +51,7 @@ interface MatrixClientCreds {
class MatrixClientPeg {
constructor() {
this.matrixClient = null;
this._justRegisteredUserId = null;
// These are the default options used when when the
// client is started in 'start'. These can be altered
@ -85,6 +86,31 @@ class MatrixClientPeg {
MatrixActionCreators.stop();
}
/*
* If we've registered a user ID we set this to the ID of the
* user we've just registered. If they then go & log in, we
* can send them to the welcome user (obviously this doesn't
* guarentee they'll get a chat with the welcome user).
*
* @param {string} uid The user ID of the user we've just registered
*/
setJustRegisteredUserId(uid) {
this._justRegisteredUserId = uid;
}
/*
* Returns true if the current user has just been registered by this
* client as determined by setJustRegisteredUserId()
*
* @returns {bool} True if user has just been registered
*/
currentUserIsJustRegistered() {
return (
this.matrixClient &&
this.matrixClient.credentials.userId === this._justRegisteredUserId
);
}
/**
* Replace this MatrixClientPeg's client with a client instance that has
* homeserver / identity server URLs and active credentials

View file

@ -1,5 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,13 +27,33 @@ export const MUTE = 'mute';
export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD];
export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY];
function _shouldShowNotifBadge(roomNotifState) {
const showBadgeInStates = [ALL_MESSAGES, ALL_MESSAGES_LOUD];
return showBadgeInStates.indexOf(roomNotifState) > -1;
export function shouldShowNotifBadge(roomNotifState) {
return BADGE_STATES.includes(roomNotifState);
}
function _shouldShowMentionBadge(roomNotifState) {
return roomNotifState !== MUTE;
export function shouldShowMentionBadge(roomNotifState) {
return MENTION_BADGE_STATES.includes(roomNotifState);
}
export function countRoomsWithNotif(rooms) {
return rooms.reduce((result, room, index) => {
const roomNotifState = getRoomNotifsState(room.roomId);
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
const isInvite = room.hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite');
const badges = notifBadges || mentionBadges || isInvite;
if (badges) {
result.count++;
if (highlight) {
result.highlight = true;
}
}
return result;
}, {count: 0, highlight: false});
}
export function aggregateNotificationCount(rooms) {
@ -41,8 +62,8 @@ export function aggregateNotificationCount(rooms) {
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState);
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
const badges = notifBadges || mentionBadges;
if (badges) {
@ -60,8 +81,8 @@ export function getRoomHasBadge(room) {
const highlight = room.getUnreadNotificationCount('highlight') > 0;
const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && _shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && _shouldShowMentionBadge(roomNotifState);
const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && shouldShowMentionBadge(roomNotifState);
return notifBadges || mentionBadges;
}

View file

@ -29,6 +29,14 @@ class ScalarAuthClient {
this.scalarToken = null;
}
/**
* Determines if setting up a ScalarAuthClient is even possible
* @returns {boolean} true if possible, false otherwise.
*/
static isPossible() {
return SdkConfig.get()['integrations_rest_url'] && SdkConfig.get()['integrations_ui_url'];
}
connect() {
return this.getScalarToken().then((tok) => {
this.scalarToken = tok;
@ -41,7 +49,8 @@ class ScalarAuthClient {
// Returns a scalar_token string
getScalarToken() {
const token = window.localStorage.getItem("mx_scalar_token");
let token = this.scalarToken;
if (!token) token = window.localStorage.getItem("mx_scalar_token");
if (!token) {
return this.registerForToken();

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,7 +16,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
@ -48,7 +48,6 @@ export default class ContextualMenu extends React.Component {
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
menuColour: PropTypes.string,
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func,
@ -157,25 +156,6 @@ export default class ContextualMenu extends React.Component {
chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted);
}
// To override the default chevron colour, if it's been set
let chevronCSS = "";
if (props.menuColour) {
chevronCSS = `
.mx_ContextualMenu_chevron_left:after {
border-right-color: ${props.menuColour};
}
.mx_ContextualMenu_chevron_right:after {
border-left-color: ${props.menuColour};
}
.mx_ContextualMenu_chevron_top:after {
border-left-color: ${props.menuColour};
}
.mx_ContextualMenu_chevron_bottom:after {
border-left-color: ${props.menuColour};
}
`;
}
const chevron = hasChevron ?
<div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace} /> :
undefined;
@ -202,10 +182,6 @@ export default class ContextualMenu extends React.Component {
menuStyle.height = props.menuHeight;
}
if (props.menuColour) {
menuStyle["backgroundColor"] = props.menuColour;
}
if (!isNaN(Number(props.menuPaddingTop))) {
menuStyle["paddingTop"] = props.menuPaddingTop;
}
@ -236,7 +212,6 @@ export default class ContextualMenu extends React.Component {
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" style={wrapperStyle}
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style>
</div>;
}
}

View file

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2017, 2018 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -861,9 +862,9 @@ export default React.createClass({
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const Spinner = sdk.getComponent('elements.Spinner');
const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
const TooltipButton = sdk.getComponent('elements.TooltipButton');
const roomsHelpNode = this.state.editing ? <ToolTipButton helpText={
const roomsHelpNode = this.state.editing ? <TooltipButton helpText={
_t(
'These rooms are displayed to community members on the community page. '+
'Community members can join the rooms by clicking on them.',

View file

@ -38,6 +38,8 @@ export default class IndicatorScrollbar extends React.Component {
this.checkOverflow = this.checkOverflow.bind(this);
this._scrollElement = null;
this._autoHideScrollbar = null;
this._likelyTrackpadUser = null;
this._checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser
this.state = {
leftIndicatorOffset: 0,
@ -129,7 +131,27 @@ export default class IndicatorScrollbar extends React.Component {
// the harshness of the scroll behaviour. Should be a value between 0 and 1.
const yRetention = 1.0;
if (Math.abs(e.deltaX) <= xyThreshold) {
// whenever we see horizontal scrolling, assume the user is on a trackpad
// for at least the next 1 minute.
const now = new Date().getTime();
if (Math.abs(e.deltaX) > 0) {
this._likelyTrackpadUser = true;
this._checkAgainForTrackpad = now + (1 * 60 * 1000);
} else {
// if we haven't seen any horizontal scrolling for a while, assume
// the user might have plugged in a mousewheel
if (this._likelyTrackpadUser && now >= this._checkAgainForTrackpad) {
this._likelyTrackpadUser = false;
}
}
// don't mess with the horizontal scroll for trackpad users
// See https://github.com/vector-im/riot-web/issues/10005
if (this._likelyTrackpadUser) {
return;
}
if (Math.abs(e.deltaX) <= xyThreshold) { // we are vertically scrolling.
// HACK: We increase the amount of scroll to counteract smooth scrolling browsers.
// Smooth scrolling browsers (Firefox) use the relative area to determine the scroll
// amount, which means the likely small area of content results in a small amount of

View file

@ -292,16 +292,6 @@ const LoggedInView = React.createClass({
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
switch (ev.keyCode) {
case KeyCode.UP:
case KeyCode.DOWN:
if (ev.altKey && !ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
const action = ev.keyCode == KeyCode.UP ?
'view_prev_room' : 'view_next_room';
dis.dispatch({action: action});
handled = true;
}
break;
case KeyCode.PAGE_UP:
case KeyCode.PAGE_DOWN:
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {

View file

@ -53,6 +53,8 @@ import { messageForSyncError } from '../../utils/ErrorUtils';
import ResizeNotifier from "../../utils/ResizeNotifier";
import { ValidatedServerConfig } from "../../utils/AutoDiscoveryUtils";
import AutoDiscoveryUtils from "../../utils/AutoDiscoveryUtils";
import DMRoomMap from '../../utils/DMRoomMap';
import { countRoomsWithNotif } from '../../RoomNotifs';
// Disable warnings for now: we use deprecated bluebird functions
// and need to migrate, but they spam the console with warnings.
@ -887,6 +889,7 @@ export default React.createClass({
}
return;
}
MatrixClientPeg.setJustRegisteredUserId(credentials.user_id);
this.onRegistered(credentials);
},
onDifferentServerClicked: (ev) => {
@ -1131,29 +1134,81 @@ export default React.createClass({
}
},
/**
* Starts a chat with the welcome user, if the user doesn't already have one
* @returns {string} The room ID of the new room, or null if no room was created
*/
async _startWelcomeUserChat() {
// We can end up with multiple tabs post-registration where the user
// might then end up with a session and we don't want them all making
// a chat with the welcome user: try to de-dupe.
// We need to wait for the first sync to complete for this to
// work though.
let waitFor;
if (!this.firstSyncComplete) {
waitFor = this.firstSyncPromise.promise;
} else {
waitFor = Promise.resolve();
}
await waitFor;
const welcomeUserRooms = DMRoomMap.shared().getDMRoomsForUserId(
this.props.config.welcomeUserId,
);
if (welcomeUserRooms.length === 0) {
const roomId = await createRoom({
dmUserId: this.props.config.welcomeUserId,
// Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId,
spinner: false, // we're already showing one: we don't need another one
});
// This is a bit of a hack, but since the deduplication relies
// on m.direct being up to date, we need to force a sync
// of the database, otherwise if the user goes to the other
// tab before the next save happens (a few minutes), the
// saved sync will be restored from the db and this code will
// run without the update to m.direct, making another welcome
// user room (it doesn't wait for new data from the server, just
// the saved sync to be loaded).
const saveWelcomeUser = (ev) => {
if (
ev.getType() == 'm.direct' &&
ev.getContent() &&
ev.getContent()[this.props.config.welcomeUserId]
) {
MatrixClientPeg.get().store.save(true);
MatrixClientPeg.get().removeListener(
"accountData", saveWelcomeUser,
);
}
};
MatrixClientPeg.get().on("accountData", saveWelcomeUser);
return roomId;
}
return null;
},
/**
* Called when a new logged in session has started
*/
_onLoggedIn: async function() {
this.setStateForNewView({ view: VIEWS.LOGGED_IN });
if (this._is_registered) {
this._is_registered = false;
if (MatrixClientPeg.currentUserIsJustRegistered()) {
MatrixClientPeg.setJustRegisteredUserId(null);
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
const roomId = await createRoom({
dmUserId: this.props.config.welcomeUserId,
// Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId,
});
// if successful, return because we're already
// viewing the welcomeUserId room
// else, if failed, fall through to view_home_page
if (roomId) {
return;
const welcomeUserRoom = await this._startWelcomeUserChat();
if (welcomeUserRoom === null) {
// We didn't rediret to the welcome user room, so show
// the homepage.
dis.dispatch({action: 'view_home_page'});
}
} else {
// The user has just logged in after registering,
// so show the homepage.
dis.dispatch({action: 'view_home_page'});
}
// The user has just logged in after registering
dis.dispatch({action: 'view_home_page'});
} else {
this._showScreenAfterLogin();
}
@ -1655,48 +1710,6 @@ export default React.createClass({
// returns a promise which resolves to the new MatrixClient
onRegistered: function(credentials) {
if (this.state.register_session_id) {
// The user came in through an email validation link. To avoid overwriting
// their session, check to make sure the session isn't someone else, and
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const sessionOwner = Lifecycle.getStoredSessionOwner();
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
if (sessionOwner && !sessionIsGuest && sessionOwner !== credentials.userId) {
console.log(
`Found a session for ${sessionOwner} but ${credentials.userId} is trying to verify their ` +
`email address. Restoring the session for ${sessionOwner} with warning.`,
);
this._loadSession();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// N.B. first param is passed to piwik and so doesn't want i18n
Modal.createTrackedDialog('Existing session on register', '',
QuestionDialog, {
title: _t('You are logged in to another account'),
description: _t(
"Thank you for verifying your email! The account you're logged into here " +
"(%(sessionUserId)s) appears to be different from the account you've verified an " +
"email for (%(verifiedUserId)s). If you would like to log in to %(verifiedUserId2)s, " +
"please log out first.", {
sessionUserId: sessionOwner,
verifiedUserId: credentials.userId,
// TODO: Fix translations to support reusing variables.
// https://github.com/vector-im/riot-web/issues/9086
verifiedUserId2: credentials.userId,
},
),
hasCancelButton: false,
});
return MatrixClientPeg.get();
}
}
// XXX: This should be in state or ideally store(s) because we risk not
// rendering the most up-to-date view of state otherwise.
this._is_registered = true;
return Lifecycle.setLoggedIn(credentials);
},
@ -1737,19 +1750,7 @@ export default React.createClass({
},
updateStatusIndicator: function(state, prevState) {
let notifCount = 0;
const rooms = MatrixClientPeg.get().getRooms();
for (let i = 0; i < rooms.length; ++i) {
if (rooms[i].hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite')) {
notifCount++;
} else if (rooms[i].getUnreadNotificationCount()) {
// if we were summing unread notifs:
// notifCount += rooms[i].getUnreadNotificationCount();
// instead, we just count the number of rooms with notifs.
notifCount++;
}
}
const notifCount = countRoomsWithNotif(MatrixClientPeg.get().getRooms()).count;
if (PlatformPeg.get()) {
PlatformPeg.get().setErrorStatus(state === 'ERROR');

View file

@ -108,6 +108,7 @@ module.exports = React.createClass({
},
componentWillMount: function() {
this._editingEnabled = SettingsStore.isFeatureEnabled("feature_message_editing");
// the event after which we put a visible unread marker on the last
// render cycle; null if readMarkerVisible was false or the RM was
// suppressed (eg because it was at the end of the timeline)
@ -585,14 +586,14 @@ module.exports = React.createClass({
<EventTile mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
replacingEventId={this._editingEnabled && mxEv.replacingEventId()}
editState={isEditing && this.props.editState}
onHeightChanged={this._onHeightChanged}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.replacementOrOwnStatus()}
eventSendStatus={mxEv.getAssociatedStatus()}
tileShape={this.props.tileShape}
isTwelveHour={this.props.isTwelveHour}
permalinkCreator={this.props.permalinkCreator}

View file

@ -333,7 +333,7 @@ module.exports = React.createClass({
if (alias.indexOf(':') == -1) {
alias = alias + ':' + this.state.roomServer;
}
this.showRoomAlias(alias);
this.showRoomAlias(alias, true);
} else {
// This is a 3rd party protocol. Let's see if we can join it
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
@ -349,7 +349,7 @@ module.exports = React.createClass({
}
MatrixClientPeg.get().getThirdpartyLocation(protocolName, fields).done((resp) => {
if (resp.length > 0 && resp[0].alias) {
this.showRoomAlias(resp[0].alias);
this.showRoomAlias(resp[0].alias, true);
} else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
@ -367,13 +367,16 @@ module.exports = React.createClass({
}
},
showRoomAlias: function(alias) {
this.showRoom(null, alias);
showRoomAlias: function(alias, autoJoin=false) {
this.showRoom(null, alias, autoJoin);
},
showRoom: function(room, room_alias) {
showRoom: function(room, room_alias, autoJoin=false) {
this.props.onFinished();
const payload = {action: 'view_room'};
const payload = {
action: 'view_room',
auto_join: autoJoin,
};
if (room) {
// Don't let the user view a room they won't be able to either
// peek or join: fail earlier so they don't have to click back

View file

@ -1523,9 +1523,11 @@ module.exports = React.createClass({
<div className="mx_RoomView">
<RoomPreviewBar
canPreview={false}
previewLoading={this.state.peekLoading}
error={this.state.roomLoadError}
loading={loading}
joining={this.state.joining}
oobData={this.props.oobData}
/>
</div>
);

View file

@ -29,6 +29,7 @@ import * as ServerType from '../../views/auth/ServerTypeSelector';
import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils";
import classNames from "classnames";
import * as Lifecycle from '../../../Lifecycle';
import MatrixClientPeg from "../../../MatrixClientPeg";
// Phases
// Show controls to configure server details
@ -96,6 +97,13 @@ module.exports = React.createClass({
// Our matrix client - part of state because we can't render the UI auth
// component without it.
matrixClient: null,
// The user ID we've just registered
registeredUsername: null,
// if a different user ID to the one we just registered is logged in,
// this is the user ID that's logged in.
differentLoggedInUserId: null,
};
},
@ -167,6 +175,8 @@ module.exports = React.createClass({
_replaceClient: async function(serverConfig) {
this.setState({
errorText: null,
serverDeadError: null,
serverErrorIsFatal: false,
// busy while we do liveness check (we need to avoid trying to render
// the UI auth component while we don't have a matrix client)
busy: true,
@ -179,7 +189,10 @@ module.exports = React.createClass({
serverConfig.hsUrl,
serverConfig.isUrl,
);
this.setState({serverIsAlive: true});
this.setState({
serverIsAlive: true,
serverErrorIsFatal: false,
});
} catch (e) {
this.setState({
busy: false,
@ -287,9 +300,29 @@ module.exports = React.createClass({
return;
}
MatrixClientPeg.setJustRegisteredUserId(response.user_id);
const newState = {
doingUIAuth: false,
registeredUsername: response.user_id,
};
// The user came in through an email validation link. To avoid overwriting
// their session, check to make sure the session isn't someone else, and
// isn't a guest user since we'll usually have set a guest user session before
// starting the registration process. This isn't perfect since it's possible
// the user had a separate guest session they didn't actually mean to replace.
const sessionOwner = Lifecycle.getStoredSessionOwner();
const sessionIsGuest = Lifecycle.getStoredSessionIsGuest();
if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) {
console.log(
`Found a session for ${sessionOwner} but ${response.userId} has just registered.`,
);
newState.differentLoggedInUserId = sessionOwner;
} else {
newState.differentLoggedInUserId = null;
}
if (response.access_token) {
const cli = await this.props.onLoggedIn({
userId: response.user_id,
@ -423,7 +456,9 @@ module.exports = React.createClass({
// If we're on a different phase, we only show the server type selector,
// which is always shown if we allow custom URLs at all.
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) {
// (if there's a fatal server error, we need to show the full server
// config as the user may need to change servers to resolve the error).
if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) {
return <div>
<ServerTypeSelector
selected={this.state.serverType}
@ -528,6 +563,7 @@ module.exports = React.createClass({
const AuthHeader = sdk.getComponent('auth.AuthHeader');
const AuthBody = sdk.getComponent("auth.AuthBody");
const AuthPage = sdk.getComponent('auth.AuthPage');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let errorText;
const err = this.state.errorText;
@ -564,28 +600,41 @@ module.exports = React.createClass({
let body;
if (this.state.completedNoSignin) {
let regDoneText;
if (this.state.formVals.password) {
if (this.state.differentLoggedInUserId) {
regDoneText = <div>
<p>{_t(
"Your new account (%(newAccountId)s) is registered, but you're already " +
"logged into a different account (%(loggedInUserId)s).", {
newAccountId: this.state.registeredUsername,
loggedInUserId: this.state.differentLoggedInUserId,
},
)}</p>
<p><AccessibleButton element="span" className="mx_linkButton" onClick={this._onLoginClickWithCheck}>
{_t("Continue with previous account")}
</AccessibleButton></p>
</div>;
} else if (this.state.formVals.password) {
// We're the client that started the registration
regDoneText = _t(
regDoneText = <h3>{_t(
"<a>Log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
);
)}</h3>;
} else {
// We're not the original client: the user probably got to us by clicking the
// email validation link. We can't offer a 'go straight to your account' link
// as we don't have the original creds.
regDoneText = _t(
regDoneText = <h3>{_t(
"You can now close this window or <a>log in</a> to your new account.", {},
{
a: (sub) => <a href="#/login" onClick={this._onLoginClickWithCheck}>{sub}</a>,
},
);
)}</h3>;
}
body = <div>
<h2>{_t("Registration Successful")}</h2>
<h3>{ regDoneText }</h3>
{ regDoneText }
</div>;
} else {
body = <div>

View file

@ -81,16 +81,10 @@ export const PasswordAuthEntry = React.createClass({
getInitialState: function() {
return {
passwordValid: false,
password: "",
};
},
focus: function() {
if (this.refs.passwordField) {
this.refs.passwordField.focus();
}
},
_onSubmit: function(e) {
e.preventDefault();
if (this.props.busy) return;
@ -98,23 +92,21 @@ export const PasswordAuthEntry = React.createClass({
this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE,
user: this.props.matrixClient.credentials.userId,
password: this.refs.passwordField.value,
password: this.state.password,
});
},
_onPasswordFieldChange: function(ev) {
// enable the submit button iff the password is non-empty
this.setState({
passwordValid: Boolean(this.refs.passwordField.value),
password: ev.target.value,
});
},
render: function() {
let passwordBoxClass = null;
if (this.props.errorText) {
passwordBoxClass = 'error';
}
const passwordBoxClass = classnames({
"error": this.props.errorText,
});
let submitButtonOrSpinner;
if (this.props.busy) {
@ -124,7 +116,7 @@ export const PasswordAuthEntry = React.createClass({
submitButtonOrSpinner = (
<input type="submit"
className="mx_Dialog_primary"
disabled={!this.state.passwordValid}
disabled={!this.state.password}
/>
);
}
@ -138,17 +130,21 @@ export const PasswordAuthEntry = React.createClass({
);
}
const Field = sdk.getComponent('elements.Field');
return (
<div>
<p>{ _t("To continue, please enter your password.") }</p>
<form onSubmit={this._onSubmit}>
<label htmlFor="passwordField">{ _t("Password:") }</label>
<input
name="passwordField"
ref="passwordField"
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<Field
id="mx_InteractiveAuthEntryComponents_password"
className={passwordBoxClass}
onChange={this._onPasswordFieldChange}
type="password"
name="passwordField"
label={_t('Password')}
autoFocus={true}
value={this.state.password}
onChange={this._onPasswordFieldChange}
/>
<div className="mx_button_row">
{ submitButtonOrSpinner }

View file

@ -104,6 +104,9 @@ export default class ServerConfig extends React.PureComponent {
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (!stateForError.isFatalError) {
this.setState({
busy: false,
});
// carry on anyway
const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true);
this.props.onServerConfigChange(result);

View file

@ -36,7 +36,7 @@ export default class DeactivateAccountDialog extends React.Component {
this._onEraseFieldChange = this._onEraseFieldChange.bind(this);
this.state = {
confirmButtonEnabled: false,
password: "",
busy: false,
shouldErase: false,
errStr: null,
@ -45,7 +45,7 @@ export default class DeactivateAccountDialog extends React.Component {
_onPasswordFieldChange(ev) {
this.setState({
confirmButtonEnabled: Boolean(ev.target.value),
password: ev.target.value,
});
}
@ -104,7 +104,7 @@ export default class DeactivateAccountDialog extends React.Component {
}
const okLabel = this.state.busy ? <Loader /> : _t('Deactivate Account');
const okEnabled = this.state.confirmButtonEnabled && !this.state.busy;
const okEnabled = this.state.password && !this.state.busy;
let cancelButton = null;
if (!this.state.busy) {
@ -113,6 +113,8 @@ export default class DeactivateAccountDialog extends React.Component {
</button>;
}
const Field = sdk.getComponent('elements.Field');
return (
<BaseDialog className="mx_DeactivateAccountDialog"
onFinished={this.props.onFinished}
@ -167,10 +169,12 @@ export default class DeactivateAccountDialog extends React.Component {
</p>
<p>{ _t("To continue, please enter your password:") }</p>
<input
<Field
id="mx_DeactivateAccountDialog_password"
type="password"
placeholder={_t("password")}
label={_t('Password')}
onChange={this._onPasswordFieldChange}
value={this.state.password}
ref={(e) => {this._passwordField = e;}}
className={passwordBoxClass}
/>

View file

@ -34,9 +34,15 @@ export default class IncomingSasDialog extends React.Component {
constructor(props) {
super(props);
let phase = PHASE_START;
if (this.props.verifier.cancelled) {
console.log("Verifier was cancelled in the background.");
phase = PHASE_CANCELLED;
}
this._showSasEvent = null;
this.state = {
phase: PHASE_START,
phase: phase,
sasVerified: false,
opponentProfile: null,
opponentProfileError: null,

View file

@ -0,0 +1,108 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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 MatrixClientPeg from "../../../MatrixClientPeg";
import { _t } from '../../../languageHandler';
import sdk from "../../../index";
import {wantsDateSeparator} from '../../../DateUtils';
import SettingsStore from '../../../settings/SettingsStore';
export default class MessageEditHistoryDialog extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
};
constructor(props) {
super(props);
this.state = {
events: [],
nextBatch: null,
isLoading: true,
isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"),
};
}
loadMoreEdits = async (backwards) => {
if (backwards || (!this.state.nextBatch && !this.state.isLoading)) {
// bail out on backwards as we only paginate in one direction
return false;
}
const opts = {from: this.state.nextBatch};
const roomId = this.props.mxEvent.getRoomId();
const eventId = this.props.mxEvent.getId();
const result = await MatrixClientPeg.get().relations(
roomId, eventId, "m.replace", "m.room.message", opts);
let resolve;
const promise = new Promise(r => resolve = r);
this.setState({
events: this.state.events.concat(result.events),
nextBatch: result.nextBatch,
isLoading: false,
}, () => {
const hasMoreResults = !!this.state.nextBatch;
resolve(hasMoreResults);
});
return promise;
}
componentDidMount() {
this.loadMoreEdits();
}
_renderEdits() {
const EditHistoryMessage = sdk.getComponent('messages.EditHistoryMessage');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const nodes = [];
let lastEvent;
this.state.events.forEach(e => {
if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) {
nodes.push(<li key={e.getTs() + "~"}><DateSeparator ts={e.getTs()} /></li>);
}
nodes.push(<EditHistoryMessage key={e.getId()} mxEvent={e} isTwelveHour={this.state.isTwelveHour} />);
lastEvent = e;
});
return nodes;
}
render() {
let content;
if (this.state.error) {
content = this.state.error;
} else if (this.state.isLoading) {
const Spinner = sdk.getComponent("elements.Spinner");
content = <Spinner />;
} else {
const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
content = (<ScrollPanel
className="mx_MessageEditHistoryDialog_scrollPanel"
onFillRequest={ this.loadMoreEdits }
stickyBottom={false}
startAtBottom={false}
>
<ul className="mx_MessageEditHistoryDialog_edits mx_MessagePanel_alwaysShowTimestamps">{this._renderEdits()}</ul>
</ScrollPanel>);
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className='mx_MessageEditHistoryDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Message edits")}>
{content}
</BaseDialog>
);
}
}

View file

@ -92,7 +92,7 @@ export default React.createClass({
<p>
{_t(
"Upgrading this room requires closing down the current " +
"instance of the room and creating a new room it its place. " +
"instance of the room and creating a new room in its place. " +
"To give room members the best possible experience, we will:",
)}
</p>

View file

@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 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.
@ -18,6 +19,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import filesize from "filesize";
export default class UploadConfirmDialog extends React.Component {
static propTypes = {
@ -49,6 +51,10 @@ export default class UploadConfirmDialog extends React.Component {
this.props.onFinished(true);
}
_onUploadAllClick = () => {
this.props.onFinished(true, true);
}
render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
@ -71,7 +77,7 @@ export default class UploadConfirmDialog extends React.Component {
preview = <div className="mx_UploadConfirmDialog_previewOuter">
<div className="mx_UploadConfirmDialog_previewInner">
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div>
<div>{this.props.file.name}</div>
<div>{this.props.file.name} ({filesize(this.props.file.size)})</div>
</div>
</div>;
} else {
@ -80,11 +86,18 @@ export default class UploadConfirmDialog extends React.Component {
<img className="mx_UploadConfirmDialog_fileIcon"
src={require("../../../../res/img/files.png")}
/>
{this.props.file.name}
{this.props.file.name} ({filesize(this.props.file.size)})
</div>
</div>;
}
let uploadAllButton;
if (this.props.currentIndex + 1 < this.props.totalFiles) {
uploadAllButton = <button onClick={this._onUploadAllClick}>
{_t("Upload all")}
</button>;
}
return (
<BaseDialog className='mx_UploadConfirmDialog'
fixedWidth={false}
@ -100,7 +113,9 @@ export default class UploadConfirmDialog extends React.Component {
hasCancel={false}
onPrimaryButtonClick={this._onUploadClick}
focus={true}
/>
>
{uploadAllButton}
</DialogButtons>
</BaseDialog>
);
}

View file

@ -240,19 +240,13 @@ export default class AppTile extends React.Component {
if (this.props.onEditClick) {
this.props.onEditClick();
} else {
// The dialog handles scalar auth for us
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this._scalarClient.connect().done(() => {
const src = this._scalarClient.getScalarInterfaceUrlForRoom(
this.props.room, 'type_' + this.props.type, this.props.id);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
}, (err) => {
this.setState({
error: err.message,
});
console.error('Error ensuring a valid scalar_token exists', err);
});
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
room: this.props.room,
screen: 'type_' + this.props.type,
integrationId: this.props.id,
}, "mx_IntegrationsManager");
}
}

View file

@ -0,0 +1,195 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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 ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames';
const InteractiveTooltipContainerId = "mx_InteractiveTooltip_Container";
// If the distance from tooltip to window edge is below this value, the tooltip
// will flip around to the other side of the target.
const MIN_SAFE_DISTANCE_TO_WINDOW_EDGE = 20;
function getOrCreateContainer() {
let container = document.getElementById(InteractiveTooltipContainerId);
if (!container) {
container = document.createElement("div");
container.id = InteractiveTooltipContainerId;
document.body.appendChild(container);
}
return container;
}
function isInRect(x, y, rect, buffer = 10) {
const { top, right, bottom, left } = rect;
return x >= (left - buffer) && x <= (right + buffer)
&& y >= (top - buffer) && y <= (bottom + buffer);
}
/*
* This style of tooltip takes a "target" element as its child and centers the
* tooltip along one edge of the target.
*/
export default class InteractiveTooltip extends React.Component {
propTypes: {
// Content to show in the tooltip
content: PropTypes.node.isRequired,
// Function to call when visibility of the tooltip changes
onVisibilityChange: PropTypes.func,
};
constructor() {
super();
this.state = {
contentRect: null,
visible: false,
};
}
componentDidUpdate() {
// Whenever this passthrough component updates, also render the tooltip
// in a separate DOM tree. This allows the tooltip content to participate
// the normal React rendering cycle: when this component re-renders, the
// tooltip content re-renders.
// Once we upgrade to React 16, this could be done a bit more naturally
// using the portals feature instead.
this.renderTooltip();
}
collectContentRect = (element) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
this.setState({
contentRect: element.getBoundingClientRect(),
});
}
collectTarget = (element) => {
this.target = element;
}
onBackgroundClick = (ev) => {
this.hideTooltip();
}
onBackgroundMouseMove = (ev) => {
const { clientX: x, clientY: y } = ev;
const { contentRect } = this.state;
const targetRect = this.target.getBoundingClientRect();
if (!isInRect(x, y, contentRect) && !isInRect(x, y, targetRect)) {
this.hideTooltip();
return;
}
}
onTargetMouseOver = (ev) => {
this.showTooltip();
}
showTooltip() {
this.setState({
visible: true,
});
if (this.props.onVisibilityChange) {
this.props.onVisibilityChange(true);
}
}
hideTooltip() {
this.setState({
visible: false,
});
if (this.props.onVisibilityChange) {
this.props.onVisibilityChange(false);
}
}
renderTooltip() {
const { contentRect, visible } = this.state;
if (!visible) {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
return null;
}
const targetRect = this.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const targetLeft = targetRect.left + window.pageXOffset;
const targetBottom = targetRect.bottom + window.pageYOffset;
const targetTop = targetRect.top + window.pageYOffset;
// Place the tooltip above the target by default. If we find that the
// tooltip content would extend past the safe area towards the window
// edge, flip around to below the target.
const position = {};
let chevronFace = null;
if (contentRect && (targetTop - contentRect.height <= MIN_SAFE_DISTANCE_TO_WINDOW_EDGE)) {
position.top = targetBottom;
chevronFace = "top";
} else {
position.bottom = window.innerHeight - targetTop;
chevronFace = "bottom";
}
// Center the tooltip horizontally with the target's center.
position.left = targetLeft + targetRect.width / 2;
const chevron = <div className={"mx_InteractiveTooltip_chevron_" + chevronFace} />;
const menuClasses = classNames({
'mx_InteractiveTooltip': true,
'mx_InteractiveTooltip_withChevron_top': chevronFace === 'top',
'mx_InteractiveTooltip_withChevron_bottom': chevronFace === 'bottom',
});
const menuStyle = {};
if (contentRect) {
menuStyle.left = `-${contentRect.width / 2}px`;
}
const tooltip = <div className="mx_InteractiveTooltip_wrapper" style={{...position}}>
<div className="mx_ContextualMenu_background"
onMouseMove={this.onBackgroundMouseMove}
onClick={this.onBackgroundClick}
/>
<div className={menuClasses}
style={menuStyle}
ref={this.collectContentRect}
>
{chevron}
{this.props.content}
</div>
</div>;
ReactDOM.render(tooltip, getOrCreateContainer());
}
render() {
// We use `cloneElement` here to append some props to the child content
// without using a wrapper element which could disrupt layout.
return React.cloneElement(this.props.children, {
ref: this.collectTarget,
onMouseOver: this.onTargetMouseOver,
});
}
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,95 +18,34 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import classNames from 'classnames';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import Modal from "../../../Modal";
import { _t } from '../../../languageHandler';
import AccessibleButton from './AccessibleButton';
export default class ManageIntegsButton extends React.Component {
constructor(props) {
super(props);
this.state = {
scalarError: null,
};
this.onManageIntegrations = this.onManageIntegrations.bind(this);
}
componentWillMount() {
ScalarMessaging.startListening();
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().done(() => {
this.forceUpdate();
}, (err) => {
this.setState({scalarError: err});
console.error('Error whilst initialising scalarClient for ManageIntegsButton', err);
});
}
}
componentWillUnmount() {
ScalarMessaging.stopListening();
}
onManageIntegrations(ev) {
onManageIntegrations = (ev) => {
ev.preventDefault();
if (this.state.scalarError && !this.scalarClient.hasCredentials()) {
return;
}
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this.scalarClient.connect().done(() => {
Modal.createDialog(IntegrationsManager, {
src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room) :
null,
}, "mx_IntegrationsManager");
}, (err) => {
this.setState({scalarError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
}
Modal.createDialog(IntegrationsManager, {
room: this.props.room,
}, "mx_IntegrationsManager");
};
render() {
let integrationsButton = <div />;
let integrationsWarningTriangle = <div />;
let integrationsErrorPopup = <div />;
if (this.scalarClient !== null) {
const integrationsButtonClasses = classNames({
mx_RoomHeader_button: true,
mx_RoomHeader_manageIntegsButton: true,
mx_ManageIntegsButton_error: !!this.state.scalarError,
});
if (this.state.scalarError && !this.scalarClient.hasCredentials()) {
integrationsWarningTriangle = <img
src={require("../../../../res/img/warning.svg")}
title={_t('Integrations Error')}
width="17"
/>;
// Popup shown when hovering over integrationsButton_error (via CSS)
integrationsErrorPopup = (
<span className="mx_ManageIntegsButton_errorPopup">
{ _t('Could not connect to the integration server') }
</span>
);
}
if (ScalarAuthClient.isPossible()) {
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
integrationsButton = (
<AccessibleButton className={integrationsButtonClasses}
<AccessibleButton
className='mx_RoomHeader_button mx_RoomHeader_manageIntegsButton'
title={_t("Manage Integrations")}
onClick={this.onManageIntegrations}
title={_t('Manage Integrations')}
>
{ integrationsWarningTriangle }
{ integrationsErrorPopup }
</AccessibleButton>
/>
);
}

View file

@ -78,6 +78,14 @@ export default class MessageEditor extends React.Component {
this.model.update(text, event.inputType, caret);
}
_insertText(textToInsert, inputType = "insertText") {
const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset);
caret.offset += textToInsert.length;
this.model.update(newText, inputType, caret);
}
_isCaretAtStart() {
const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection());
return caret.offset === 0;
@ -92,7 +100,7 @@ export default class MessageEditor extends React.Component {
// insert newline on Shift+Enter
if (event.shiftKey && event.key === "Enter") {
event.preventDefault(); // just in case the browser does support this
document.execCommand("insertHTML", undefined, "\n");
this._insertText("\n");
return;
}
// autocomplete or enter to send below shouldn't have any modifier keys pressed.
@ -150,16 +158,28 @@ export default class MessageEditor extends React.Component {
dis.dispatch({action: 'focus_composer'});
}
_isEmote() {
const firstPart = this.model.parts[0];
return firstPart && firstPart.type === "plain" && firstPart.text.startsWith("/me ");
}
_sendEdit = () => {
const isEmote = this._isEmote();
let model = this.model;
if (isEmote) {
// trim "/me "
model = model.clone();
model.removeText({index: 0, offset: 0}, 4);
}
const newContent = {
"msgtype": "m.text",
"body": textSerialize(this.model),
"msgtype": isEmote ? "m.emote" : "m.text",
"body": textSerialize(model),
};
const contentBody = {
msgtype: newContent.msgtype,
body: ` * ${newContent.body}`,
};
const formattedBody = htmlSerializeIfNeeded(this.model);
const formattedBody = htmlSerializeIfNeeded(model);
if (formattedBody) {
newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody;
@ -232,7 +252,7 @@ export default class MessageEditor extends React.Component {
parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p));
} else {
// otherwise, parse the body of the event
parts = parseEvent(editState.getEvent(), room, this.context.matrixClient);
parts = parseEvent(editState.getEvent(), partCreator);
}
return new EditorModel(

View file

@ -1,5 +1,6 @@
/*
Copyright 2017 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,7 +19,7 @@ import React from 'react';
import sdk from '../../../index';
module.exports = React.createClass({
displayName: 'ToolTipButton',
displayName: 'TooltipButton',
getInitialState: function() {
return {
@ -41,12 +42,12 @@ module.exports = React.createClass({
render: function() {
const Tooltip = sdk.getComponent("elements.Tooltip");
const tip = this.state.hover ? <Tooltip
className="mx_ToolTipButton_container"
tooltipClassName="mx_ToolTipButton_helpText"
className="mx_TooltipButton_container"
tooltipClassName="mx_TooltipButton_helpText"
label={this.props.helpText}
/> : <div />;
return (
<div className="mx_ToolTipButton" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} >
<div className="mx_TooltipButton" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} >
?
{ tip }
</div>

View file

@ -224,7 +224,7 @@ module.exports = React.createClass({
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">
{ this.state.groupRoom.canonical_alias }
{ this.state.groupRoom.canonicalAlias }
</div>
</div>

View file

@ -0,0 +1,61 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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 * as HtmlUtils from '../../../HtmlUtils';
import {formatTime} from '../../../DateUtils';
import {MatrixEvent} from 'matrix-js-sdk';
import {pillifyLinks} from '../../../utils/pillify';
export default class EditHistoryMessage extends React.PureComponent {
static propTypes = {
// the message event being edited
mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired,
};
componentDidMount() {
pillifyLinks(this.refs.content.children, this.props.mxEvent);
}
componentDidUpdate() {
pillifyLinks(this.refs.content.children, this.props.mxEvent);
}
render() {
const {mxEvent} = this.props;
const originalContent = mxEvent.getOriginalContent();
const content = originalContent["m.new_content"] || originalContent;
const contentElements = HtmlUtils.bodyToHtml(content);
let contentContainer;
if (mxEvent.getContent().msgtype === "m.emote") {
const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
contentContainer = (<div className="mx_EventTile_content" ref="content">*&nbsp;
<span className="mx_MEmoteBody_sender">{ name }</span>
&nbsp;{contentElements}
</div>);
} else {
contentContainer = (<div className="mx_EventTile_content" ref="content">{contentElements}</div>);
}
const timestamp = formatTime(new Date(mxEvent.getTs()), this.props.isTwelveHour);
return <li className="mx_EventTile">
<div className="mx_EventTile_line">
<span className="mx_MessageTimestamp">{timestamp}</span>
{ contentContainer }
</div>
</li>;
}
}

View file

@ -57,7 +57,7 @@ export default class MessageActionBar extends React.PureComponent {
this.props.onFocusChange(focused);
}
onCryptoClicked = () => {
onCryptoClick = () => {
const event = this.props.mxEvent;
Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
import('../../../async-components/views/dialogs/EncryptedEventDialog'),
@ -89,7 +89,7 @@ export default class MessageActionBar extends React.PureComponent {
let e2eInfoCallback = null;
if (this.props.mxEvent.isEncrypted()) {
e2eInfoCallback = () => this.onCryptoClicked();
e2eInfoCallback = () => this.onCryptoClick();
}
const menuOptions = {
@ -131,43 +131,28 @@ export default class MessageActionBar extends React.PureComponent {
return SettingsStore.isFeatureEnabled("feature_message_editing");
}
renderAgreeDimension() {
renderReactButton() {
if (!this.isReactionsEnabled()) {
return null;
}
const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
return <ReactionDimension
title={_t("Agree or Disagree")}
options={["👍", "👎"]}
reactions={this.props.reactions}
mxEvent={this.props.mxEvent}
/>;
}
const ReactMessageAction = sdk.getComponent('messages.ReactMessageAction');
const { mxEvent, reactions } = this.props;
renderLikeDimension() {
if (!this.isReactionsEnabled()) {
return null;
}
const ReactionDimension = sdk.getComponent('messages.ReactionDimension');
return <ReactionDimension
title={_t("Like or Dislike")}
options={["🙂", "😔"]}
reactions={this.props.reactions}
mxEvent={this.props.mxEvent}
return <ReactMessageAction
mxEvent={mxEvent}
reactions={reactions}
onFocusChange={this.onFocusChange}
/>;
}
render() {
let agreeDimensionReactionButtons;
let likeDimensionReactionButtons;
let reactButton;
let replyButton;
let editButton;
if (isContentActionable(this.props.mxEvent)) {
agreeDimensionReactionButtons = this.renderAgreeDimension();
likeDimensionReactionButtons = this.renderLikeDimension();
reactButton = this.renderReactButton();
replyButton = <span className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
title={_t("Reply")}
onClick={this.onReplyClick}
@ -181,8 +166,7 @@ export default class MessageActionBar extends React.PureComponent {
}
return <div className="mx_MessageActionBar">
{agreeDimensionReactionButtons}
{likeDimensionReactionButtons}
{reactButton}
{replyButton}
{editButton}
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"

View file

@ -0,0 +1,97 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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';
export default class ReactMessageAction extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: PropTypes.object,
onFocusChange: PropTypes.func,
}
constructor(props) {
super(props);
if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange);
}
}
onFocusChange = (focused) => {
if (!this.props.onFocusChange) {
return;
}
this.props.onFocusChange(focused);
}
componentDidUpdate(prevProps) {
if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange);
this.props.reactions.on("Relations.redaction", this.onReactionsChange);
this.onReactionsChange();
}
}
componentWillUnmount() {
if (this.props.reactions) {
this.props.reactions.removeListener(
"Relations.add",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
}
}
onReactionsChange = () => {
// Force a re-render of the tooltip because a change in the reactions
// set means the event tile's layout may have changed and possibly
// altered the location where the tooltip should be shown.
this.forceUpdate();
}
render() {
const ReactionsQuickTooltip = sdk.getComponent('messages.ReactionsQuickTooltip');
const InteractiveTooltip = sdk.getComponent('elements.InteractiveTooltip');
const { mxEvent, reactions } = this.props;
const content = <ReactionsQuickTooltip
mxEvent={mxEvent}
reactions={reactions}
/>;
return <InteractiveTooltip
content={content}
onVisibilityChange={this.onFocusChange}
>
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton" />
</InteractiveTooltip>;
}
}

View file

@ -1,176 +0,0 @@
/*
Copyright 2019 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 classNames from 'classnames';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default class ReactionDimension extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
// Array of strings containing the emoji for each option
options: PropTypes.array.isRequired,
title: PropTypes.string,
// The Relations model from the JS SDK for reactions
reactions: PropTypes.object,
};
constructor(props) {
super(props);
this.state = this.getSelection();
if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange);
}
}
componentDidUpdate(prevProps) {
if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange);
this.props.reactions.on("Relations.redaction", this.onReactionsChange);
this.onReactionsChange();
}
}
componentWillUnmount() {
if (this.props.reactions) {
this.props.reactions.removeListener(
"Relations.add",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
}
}
onReactionsChange = () => {
this.setState(this.getSelection());
}
getSelection() {
const myReactions = this.getMyReactions();
if (!myReactions) {
return {
selectedOption: null,
selectedReactionEvent: null,
};
}
const { options } = this.props;
let selectedOption = null;
let selectedReactionEvent = null;
for (const option of options) {
const reactionForOption = myReactions.find(mxEvent => {
if (mxEvent.isRedacted()) {
return false;
}
return mxEvent.getRelation().key === option;
});
if (!reactionForOption) {
continue;
}
if (selectedOption) {
// If there are multiple selected values (only expected to occur via
// non-Riot clients), then act as if none are selected.
return {
selectedOption: null,
selectedReactionEvent: null,
};
}
selectedOption = option;
selectedReactionEvent = reactionForOption;
}
return { selectedOption, selectedReactionEvent };
}
getMyReactions() {
const reactions = this.props.reactions;
if (!reactions) {
return null;
}
const userId = MatrixClientPeg.get().getUserId();
const myReactions = reactions.getAnnotationsBySender()[userId];
if (!myReactions) {
return null;
}
return [...myReactions.values()];
}
onOptionClick = (ev) => {
const { key } = ev.target.dataset;
this.toggleDimension(key);
}
toggleDimension(key) {
const { selectedOption, selectedReactionEvent } = this.state;
const newSelectedOption = selectedOption !== key ? key : null;
this.setState({
selectedOption: newSelectedOption,
});
if (selectedReactionEvent) {
MatrixClientPeg.get().redactEvent(
this.props.mxEvent.getRoomId(),
selectedReactionEvent.getId(),
);
}
if (newSelectedOption) {
MatrixClientPeg.get().sendEvent(this.props.mxEvent.getRoomId(), "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": this.props.mxEvent.getId(),
"key": newSelectedOption,
},
});
}
}
render() {
const { selectedOption } = this.state;
const { options } = this.props;
const items = options.map(option => {
const disabled = selectedOption && selectedOption !== option;
const classes = classNames({
mx_ReactionDimension_disabled: disabled,
});
return <span key={option}
data-key={option}
className={classes}
onClick={this.onOptionClick}
>
{option}
</span>;
});
return <span className="mx_ReactionDimension"
title={this.props.title}
aria-hidden={true}
>
{items}
</span>;
}
}

View file

@ -0,0 +1,68 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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 classNames from 'classnames';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default class ReactionTooltipButton extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
// The reaction content / key / emoji
content: PropTypes.string.isRequired,
title: PropTypes.string,
// A possible Matrix event if the current user has voted for this type
myReactionEvent: PropTypes.object,
};
onClick = (ev) => {
const { mxEvent, myReactionEvent, content } = this.props;
if (myReactionEvent) {
MatrixClientPeg.get().redactEvent(
mxEvent.getRoomId(),
myReactionEvent.getId(),
);
} else {
MatrixClientPeg.get().sendEvent(mxEvent.getRoomId(), "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": mxEvent.getId(),
"key": content,
},
});
}
}
render() {
const { content, myReactionEvent } = this.props;
const classes = classNames({
mx_ReactionTooltipButton: true,
mx_ReactionTooltipButton_selected: !!myReactionEvent,
});
return <span className={classes}
data-key={content}
title={this.props.title}
aria-hidden={true}
onClick={this.onClick}
>
{content}
</span>;
}
}

View file

@ -0,0 +1,195 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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 { _t } from '../../../languageHandler';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import { unicodeToShortcode } from '../../../HtmlUtils';
export default class ReactionsQuickTooltip extends React.PureComponent {
static propTypes = {
mxEvent: PropTypes.object.isRequired,
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: PropTypes.object,
};
constructor(props) {
super(props);
if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange);
}
this.state = {
hoveredItem: null,
myReactions: this.getMyReactions(),
};
}
componentDidUpdate(prevProps) {
if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange);
this.props.reactions.on("Relations.redaction", this.onReactionsChange);
this.onReactionsChange();
}
}
componentWillUnmount() {
if (this.props.reactions) {
this.props.reactions.removeListener(
"Relations.add",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
}
}
onReactionsChange = () => {
this.setState({
myReactions: this.getMyReactions(),
});
}
getMyReactions() {
const reactions = this.props.reactions;
if (!reactions) {
return null;
}
const userId = MatrixClientPeg.get().getUserId();
const myReactions = reactions.getAnnotationsBySender()[userId];
if (!myReactions) {
return null;
}
return [...myReactions.values()];
}
onMouseOver = (ev) => {
const { key } = ev.target.dataset;
const item = this.items.find(({ content }) => content === key);
this.setState({
hoveredItem: item,
});
}
onMouseOut = (ev) => {
this.setState({
hoveredItem: null,
});
}
get items() {
return [
{
content: "👍",
title: _t("Agree"),
},
{
content: "👎",
title: _t("Disagree"),
},
{
content: "😄",
title: _t("Happy"),
},
{
content: "🎉",
title: _t("Party Popper"),
},
{
content: "😕",
title: _t("Confused"),
},
{
content: "❤️",
title: _t("Heart"),
},
{
content: "🚀",
title: _t("Rocket"),
},
{
content: "👀",
title: _t("Eyes"),
},
];
}
render() {
const { mxEvent } = this.props;
const { myReactions, hoveredItem } = this.state;
const ReactionTooltipButton = sdk.getComponent('messages.ReactionTooltipButton');
const buttons = this.items.map(({ content, title }) => {
const myReactionEvent = myReactions && myReactions.find(mxEvent => {
if (mxEvent.isRedacted()) {
return false;
}
return mxEvent.getRelation().key === content;
});
return <ReactionTooltipButton
key={content}
content={content}
title={title}
mxEvent={mxEvent}
myReactionEvent={myReactionEvent}
/>;
});
let label = " "; // non-breaking space to keep layout the same when empty
if (hoveredItem) {
const { content, title } = hoveredItem;
let shortcodeLabel;
const shortcode = unicodeToShortcode(content);
if (shortcode) {
shortcodeLabel = <span className="mx_ReactionsQuickTooltip_shortcode">
{shortcode}
</span>;
}
label = <div className="mx_ReactionsQuickTooltip_label">
<span className="mx_ReactionsQuickTooltip_title">
{title}
</span>
{shortcodeLabel}
</div>;
}
return <div className="mx_ReactionsQuickTooltip"
onMouseOver={this.onMouseOver}
onMouseOut={this.onMouseOut}
>
<div className="mx_ReactionsQuickTooltip_buttons">
{buttons}
</div>
{label}
</div>;
}
}

View file

@ -18,10 +18,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { isContentActionable } from '../../../utils/EventUtils';
import { isSingleEmoji } from '../../../HtmlUtils';
import MatrixClientPeg from '../../../MatrixClientPeg';
// The maximum number of reactions to initially show on a message.
const MAX_ITEMS_WHEN_LIMITED = 8;
export default class ReactionsRow extends React.PureComponent {
static propTypes = {
// The event we're displaying reactions for
@ -41,6 +45,7 @@ export default class ReactionsRow extends React.PureComponent {
this.state = {
myReactions: this.getMyReactions(),
showAll: false,
};
}
@ -94,16 +99,22 @@ export default class ReactionsRow extends React.PureComponent {
return [...myReactions.values()];
}
onShowAllClick = () => {
this.setState({
showAll: true,
});
}
render() {
const { mxEvent, reactions } = this.props;
const { myReactions } = this.state;
const { myReactions, showAll } = this.state;
if (!reactions || !isContentActionable(mxEvent)) {
return null;
}
const ReactionsRowButton = sdk.getComponent('messages.ReactionsRowButton');
const items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
let items = reactions.getSortedAnnotationsByKey().map(([content, events]) => {
if (!isSingleEmoji(content)) {
return null;
}
@ -125,10 +136,26 @@ export default class ReactionsRow extends React.PureComponent {
reactionEvents={events}
myReactionEvent={myReactionEvent}
/>;
});
}).filter(item => !!item);
// Show the first MAX_ITEMS if there are MAX_ITEMS + 1 or more items.
// The "+ 1" ensure that the "show all" reveals something that takes up
// more space than the button itself.
let showAllButton;
if ((items.length > MAX_ITEMS_WHEN_LIMITED + 1) && !showAll) {
items = items.slice(0, MAX_ITEMS_WHEN_LIMITED);
showAllButton = <a
className="mx_ReactionsRow_showAll"
href="#"
onClick={this.onShowAllClick}
>
{_t("Show all")}
</a>;
}
return <div className="mx_ReactionsRow">
{items}
{showAllButton}
</div>;
}
}

View file

@ -30,12 +30,11 @@ import Modal from '../../../Modal';
import SdkConfig from '../../../SdkConfig';
import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import * as ContextualMenu from '../../structures/ContextualMenu';
import SettingsStore from "../../../settings/SettingsStore";
import PushProcessor from 'matrix-js-sdk/lib/pushprocessor';
import ReplyThread from "../elements/ReplyThread";
import {host as matrixtoHost} from '../../../matrix-to';
import {pillifyLinks} from '../../../utils/pillify';
module.exports = React.createClass({
displayName: 'TextualBody',
@ -99,7 +98,7 @@ module.exports = React.createClass({
// pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer
// are still sent as plaintext URLs. If these are ever pillified in the composer,
// we should be pillify them here by doing the linkifying BEFORE the pillifying.
this.pillifyLinks(this.refs.content.children);
pillifyLinks(this.refs.content.children, this.props.mxEvent);
HtmlUtils.linkifyElement(this.refs.content);
this.calculateUrlPreview();
@ -184,98 +183,6 @@ module.exports = React.createClass({
}
},
pillifyLinks: function(nodes) {
const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar");
let node = nodes[0];
while (node) {
let pillified = false;
if (node.tagName === "A" && node.getAttribute("href")) {
const href = node.getAttribute("href");
// If the link is a (localised) matrix.to link, replace it with a pill
const Pill = sdk.getComponent('elements.Pill');
if (Pill.isMessagePillUrl(href)) {
const pillContainer = document.createElement('span');
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pill = <Pill
url={href}
inMessage={true}
room={room}
shouldShowPillAvatar={shouldShowPillAvatar}
/>;
ReactDOM.render(pill, pillContainer);
node.parentNode.replaceChild(pillContainer, node);
// Pills within pills aren't going to go well, so move on
pillified = true;
// update the current node with one that's now taken its place
node = pillContainer;
}
} else if (node.nodeType === Node.TEXT_NODE) {
const Pill = sdk.getComponent('elements.Pill');
let currentTextNode = node;
const roomNotifTextNodes = [];
// Take a textNode and break it up to make all the instances of @room their
// own textNode, adding those nodes to roomNotifTextNodes
while (currentTextNode !== null) {
const roomNotifPos = Pill.roomNotifPos(currentTextNode.textContent);
let nextTextNode = null;
if (roomNotifPos > -1) {
let roomTextNode = currentTextNode;
if (roomNotifPos > 0) roomTextNode = roomTextNode.splitText(roomNotifPos);
if (roomTextNode.textContent.length > Pill.roomNotifLen()) {
nextTextNode = roomTextNode.splitText(Pill.roomNotifLen());
}
roomNotifTextNodes.push(roomTextNode);
}
currentTextNode = nextTextNode;
}
if (roomNotifTextNodes.length > 0) {
const pushProcessor = new PushProcessor(MatrixClientPeg.get());
const atRoomRule = pushProcessor.getPushRuleById(".m.rule.roomnotif");
if (atRoomRule && pushProcessor.ruleMatchesEvent(atRoomRule, this.props.mxEvent)) {
// Now replace all those nodes with Pills
for (const roomNotifTextNode of roomNotifTextNodes) {
// Set the next node to be processed to the one after the node
// we're adding now, since we've just inserted nodes into the structure
// we're iterating over.
// Note we've checked roomNotifTextNodes.length > 0 so we'll do this at least once
node = roomNotifTextNode.nextSibling;
const pillContainer = document.createElement('span');
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pill = <Pill
type={Pill.TYPE_AT_ROOM_MENTION}
inMessage={true}
room={room}
shouldShowPillAvatar={true}
/>;
ReactDOM.render(pill, pillContainer);
roomNotifTextNode.parentNode.replaceChild(pillContainer, roomNotifTextNode);
}
// Nothing else to do for a text node (and we don't need to advance
// the loop pointer because we did it above)
continue;
}
}
}
if (node.childNodes && node.childNodes.length && !pillified) {
this.pillifyLinks(node.childNodes);
}
node = node.nextSibling;
}
},
findLinks: function(nodes) {
let links = [];
@ -448,6 +355,11 @@ module.exports = React.createClass({
this.setState({editedMarkerHovered: false});
},
_openHistoryDialog: async function() {
const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
Modal.createDialog(MessageEditHistoryDialog, {mxEvent: this.props.mxEvent});
},
_renderEditedMarker: function() {
let editedTooltip;
if (this.state.editedMarkerHovered) {
@ -456,12 +368,13 @@ module.exports = React.createClass({
const date = editEvent && formatDate(editEvent.getDate());
editedTooltip = <Tooltip
tooltipClassName="mx_Tooltip_timeline"
label={_t("Edited at %(date)s", {date})}
label={_t("Edited at %(date)s. Click to view edits.", {date})}
/>;
}
return (
<div
key="editedMarker" className="mx_EventTile_edited"
onClick={this._openHistoryDialog}
onMouseEnter={this._onMouseEnterEditedMarker}
onMouseLeave={this._onMouseLeaveEditedMarker}
>{editedTooltip}<span>{`(${_t("edited")})`}</span></div>

View file

@ -24,8 +24,6 @@ import AppTile from '../elements/AppTile';
import Modal from '../../../Modal';
import dis from '../../../dispatcher';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../utils/WidgetUtils';
@ -63,20 +61,6 @@ module.exports = React.createClass({
},
componentDidMount: function() {
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().then(() => {
this.forceUpdate();
}).catch((e) => {
console.log('Failed to connect to integrations server');
// TODO -- Handle Scalar errors
// this.setState({
// scalar_error: err,
// });
});
}
this.dispatcherRef = dis.register(this.onAction);
},
@ -144,16 +128,10 @@ module.exports = React.createClass({
_launchManageIntegrations: function() {
const IntegrationsManager = sdk.getComponent('views.settings.IntegrationsManager');
this.scalarClient.connect().done(() => {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room, 'add_integ') :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, 'mx_IntegrationsManager');
}, (err) => {
console.error('Error ensuring a valid scalar_token exists', err);
});
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
room: this.props.room,
screen: 'add_integ',
}, 'mx_IntegrationsManager');
},
onClickAddWidget: function(e) {

View file

@ -1,6 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -403,7 +404,7 @@ module.exports = withMatrixClient(React.createClass({
});
},
onCryptoClicked: function(e) {
onCryptoClick: function(e) {
const event = this.props.mxEvent;
Modal.createTrackedDialogAsync('Encrypted Event Dialog', '',
@ -439,7 +440,7 @@ module.exports = withMatrixClient(React.createClass({
_renderE2EPadlock: function() {
const ev = this.props.mxEvent;
const props = {onClick: this.onCryptoClicked};
const props = {onClick: this.onCryptoClick};
// event could not be decrypted
if (ev.getContent().msgtype === 'm.bad.encrypted') {
@ -670,13 +671,13 @@ module.exports = withMatrixClient(React.createClass({
{'requestLink': (sub) => <a onClick={this.onRequestKeysClick}>{ sub }</a>},
);
const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
const TooltipButton = sdk.getComponent('elements.TooltipButton');
const keyRequestInfo = isEncryptionFailure ?
<div className="mx_EventTile_keyRequestInfo">
<span className="mx_EventTile_keyRequestInfo_text">
{ keyRequestInfoContent }
</span>
<ToolTipButton helpText={keyRequestHelpText} />
<TooltipButton helpText={keyRequestHelpText} />
</div> : null;
let reactionsRow;
@ -828,7 +829,7 @@ module.exports.haveTileForEvent = function(e) {
if (e.isRedacted() && !isMessageEvent(e)) return false;
// No tile for replacement events since they update the original tile
if (e.isRelation("m.replace")) return false;
if (e.isRelation("m.replace") && SettingsStore.isFeatureEnabled("feature_message_editing")) return false;
const handler = getHandlerTile(e);
if (handler === undefined) return false;

View file

@ -60,6 +60,7 @@ import ReplyThread from "../elements/ReplyThread";
import {ContentHelpers} from 'matrix-js-sdk';
import AccessibleButton from '../elements/AccessibleButton';
import {findEditableEvent} from '../../../utils/EventUtils';
import ComposerHistoryManager from "../../../ComposerHistoryManager";
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
@ -140,6 +141,7 @@ export default class MessageComposerInput extends React.Component {
client: MatrixClient;
autocomplete: Autocomplete;
historyManager: ComposerHistoryManager;
constructor(props, context) {
super(props, context);
@ -329,6 +331,7 @@ export default class MessageComposerInput extends React.Component {
componentWillMount() {
this.dispatcherRef = dis.register(this.onAction);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
}
componentWillUnmount() {
@ -679,14 +682,6 @@ export default class MessageComposerInput extends React.Component {
if (this.autocomplete.countCompletions() > 0) {
if (!(ev.ctrlKey || ev.shiftKey || ev.altKey || ev.metaKey)) {
switch (ev.keyCode) {
case KeyCode.LEFT:
this.autocomplete.moveSelection(-1);
ev.preventDefault();
return true;
case KeyCode.RIGHT:
this.autocomplete.moveSelection(+1);
ev.preventDefault();
return true;
case KeyCode.UP:
this.autocomplete.moveSelection(-1);
ev.preventDefault();
@ -1062,6 +1057,7 @@ export default class MessageComposerInput extends React.Component {
if (cmd) {
if (!cmd.error) {
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
this.setState({
editorState: this.createEditorState(),
}, ()=>{
@ -1139,6 +1135,8 @@ export default class MessageComposerInput extends React.Component {
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
let sendTextFn = ContentHelpers.makeTextMessage;
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
if (commandText && commandText.startsWith('/me')) {
if (replyingToEv) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -1198,19 +1196,31 @@ export default class MessageComposerInput extends React.Component {
};
onVerticalArrow = (e, up) => {
if (e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return;
// Select history
const selection = this.state.editorState.selection;
if (e.ctrlKey || e.shiftKey || e.metaKey) return;
// selection must be collapsed
const selection = this.state.editorState.selection;
if (!selection.isCollapsed) return;
const document = this.state.editorState.document;
// and we must be at the edge of the document (up=start, down=end)
const document = this.state.editorState.document;
if (up) {
if (!selection.anchor.isAtStartOfNode(document)) return;
} else {
if (!selection.anchor.isAtEndOfNode(document)) return;
}
const editingEnabled = SettingsStore.isFeatureEnabled("feature_message_editing");
const shouldSelectHistory = (editingEnabled && e.altKey) || !editingEnabled;
const shouldEditLastMessage = editingEnabled && !e.altKey && up;
if (shouldSelectHistory) {
// Try select composer history
const selected = this.selectHistory(up);
if (selected) {
// We're selecting history, so prevent the key event from doing anything else
e.preventDefault();
}
} else if (shouldEditLastMessage) {
const editEvent = findEditableEvent(this.props.room, false);
if (editEvent) {
// We're selecting history, so prevent the key event from doing anything else
@ -1223,6 +1233,54 @@ export default class MessageComposerInput extends React.Component {
}
};
selectHistory = (up) => {
const delta = up ? -1 : 1;
// True if we are not currently selecting history, but composing a message
if (this.historyManager.currentIndex === this.historyManager.history.length) {
// We can't go any further - there isn't any more history, so nop.
if (!up) {
return;
}
this.setState({
currentlyComposedEditorState: this.state.editorState,
});
} else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
// True when we return to the message being composed currently
this.setState({
editorState: this.state.currentlyComposedEditorState,
});
this.historyManager.currentIndex = this.historyManager.history.length;
return;
}
let editorState;
const historyItem = this.historyManager.getItem(delta);
if (!historyItem) return;
if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) {
editorState = this.richToMdEditorState(historyItem.value);
} else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) {
editorState = this.mdToRichEditorState(historyItem.value);
} else {
editorState = historyItem.value;
}
// Move selection to the end of the selected history
const change = editorState.change().moveToEndOfNode(editorState.document);
// We don't call this.onChange(change) now, as fixups on stuff like pills
// should already have been done and persisted in the history.
editorState = change.value;
this.suppressAutoComplete = true;
this.setState({ editorState }, ()=>{
this._editor.focus();
});
return true;
};
onTab = async (e) => {
this.setState({
someCompletions: null,

View file

@ -66,6 +66,7 @@ module.exports = React.createClass({
error: PropTypes.object,
canPreview: PropTypes.bool,
previewLoading: PropTypes.bool,
room: PropTypes.object,
// When a spinner is present, a spinnerState can be specified to indicate the
@ -254,6 +255,8 @@ module.exports = React.createClass({
},
render: function() {
const Spinner = sdk.getComponent('elements.Spinner');
let showSpinner = false;
let darkStyle = false;
let title;
@ -262,6 +265,7 @@ module.exports = React.createClass({
let primaryActionLabel;
let secondaryActionHandler;
let secondaryActionLabel;
let footer;
const messageCase = this._getMessageCase();
switch (messageCase) {
@ -287,6 +291,14 @@ module.exports = React.createClass({
primaryActionHandler = this.onRegisterClick;
secondaryActionLabel = _t("Sign In");
secondaryActionHandler = this.onLoginClick;
if (this.props.previewLoading) {
footer = (
<div>
<Spinner w={20} h={20}/>
{_t("Loading room preview")}
</div>
);
}
break;
}
case MessageCase.Kicked: {
@ -433,7 +445,6 @@ module.exports = React.createClass({
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const Spinner = sdk.getComponent('elements.Spinner');
let subTitleElements;
if (subTitle) {
@ -484,6 +495,9 @@ module.exports = React.createClass({
{ secondaryButton }
{ primaryButton }
</div>
<div className="mx_RoomPreviewBar_footer">
{ footer }
</div>
</div>
);
},

View file

@ -2,6 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -67,14 +68,6 @@ module.exports = React.createClass({
});
},
_shouldShowNotifBadge: function() {
return RoomNotifs.BADGE_STATES.includes(this.state.notifState);
},
_shouldShowMentionBadge: function() {
return RoomNotifs.MENTION_BADGE_STATES.includes(this.state.notifState);
},
_isDirectMessageRoom: function(roomId) {
const dmRooms = DMRoomMap.shared().getUserIdForRoomId(roomId);
return Boolean(dmRooms);
@ -301,8 +294,8 @@ module.exports = React.createClass({
const notificationCount = this.props.notificationCount;
// var highlightCount = this.props.room.getUnreadNotificationCount("highlight");
const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge();
const mentionBadges = this.props.highlight && this._shouldShowMentionBadge();
const notifBadges = notificationCount > 0 && RoomNotifs.shouldShowNotifBadge(this.state.notifState);
const mentionBadges = this.props.highlight && RoomNotifs.shouldShowMentionBadge(this.state.notifState);
const badges = notifBadges || mentionBadges;
let subtext = null;

View file

@ -97,20 +97,22 @@ module.exports = React.createClass({
return (
<div className="mx_RoomUpgradeWarningBar">
<div className="mx_RoomUpgradeWarningBar_header">
{_t(
"This room is running room version <roomVersion />, which this homeserver has " +
"marked as <i>unstable</i>.",
{},
{
"roomVersion": () => <code>{this.props.room.getVersion()}</code>,
"i": (sub) => <i>{sub}</i>,
},
)}
</div>
{doUpgradeWarnings}
<div className="mx_RoomUpgradeWarningBar_small">
{_t("Only room administrators will see this warning")}
<div className="mx_RoomUpgradeWarningBar_wrapped">
<div className="mx_RoomUpgradeWarningBar_header">
{_t(
"This room is running room version <roomVersion />, which this homeserver has " +
"marked as <i>unstable</i>.",
{},
{
"roomVersion": () => <code>{this.props.room.getVersion()}</code>,
"i": (sub) => <i>{sub}</i>,
},
)}
</div>
{doUpgradeWarnings}
<div className="mx_RoomUpgradeWarningBar_small">
{_t("Only room administrators will see this warning")}
</div>
</div>
</div>
);

View file

@ -14,12 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { _t } from '../../../languageHandler';
import {_t, _td} from '../../../languageHandler';
import AppTile from '../elements/AppTile';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig';
import ScalarAuthClient from '../../../ScalarAuthClient';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
@ -53,6 +52,9 @@ export default class Stickerpicker extends React.Component {
this.popoverWidth = 300;
this.popoverHeight = 300;
// This is loaded by _acquireScalarClient on an as-needed basis.
this.scalarClient = null;
this.state = {
showStickers: false,
imError: null,
@ -63,14 +65,34 @@ export default class Stickerpicker extends React.Component {
};
}
_removeStickerpickerWidgets() {
_acquireScalarClient() {
if (this.scalarClient) return Promise.resolve(this.scalarClient);
if (ScalarAuthClient.isPossible()) {
this.scalarClient = new ScalarAuthClient();
return this.scalarClient.connect().then(() => {
this.forceUpdate();
return this.scalarClient;
}).catch((e) => {
this._imError(_td("Failed to connect to integrations server"), e);
});
} else {
this._imError(_td("No integrations server is configured to manage stickers with"));
}
}
async _removeStickerpickerWidgets() {
const scalarClient = await this._acquireScalarClient();
console.warn('Removing Stickerpicker widgets');
if (this.state.widgetId) {
this.scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
console.warn('Assets disabled');
}).catch((err) => {
console.error('Failed to disable assets');
});
if (scalarClient) {
scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
console.warn('Assets disabled');
}).catch((err) => {
console.error('Failed to disable assets');
});
} else {
console.error("Cannot disable assets: no scalar client");
}
} else {
console.warn('No widget ID specified, not disabling assets');
}
@ -87,19 +109,7 @@ export default class Stickerpicker extends React.Component {
// Close the sticker picker when the window resizes
window.addEventListener('resize', this._onResize);
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
this.scalarClient.connect().then(() => {
this.forceUpdate();
}).catch((e) => {
this._imError("Failed to connect to integrations server", e);
});
}
if (!this.state.imError) {
this.dispatcherRef = dis.register(this._onWidgetAction);
}
this.dispatcherRef = dis.register(this._onWidgetAction);
// Track updates to widget state in account data
MatrixClientPeg.get().on('accountData', this._updateWidget);
@ -126,7 +136,7 @@ export default class Stickerpicker extends React.Component {
console.error(errorMsg, e);
this.setState({
showStickers: false,
imError: errorMsg,
imError: _t(errorMsg),
});
}
@ -339,22 +349,13 @@ export default class Stickerpicker extends React.Component {
*/
_launchManageIntegrations() {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
this.scalarClient.connect().done(() => {
const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ?
this.scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
'type_' + widgetType,
this.state.widgetId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
this.setState({showStickers: false});
}, (err) => {
this.setState({imError: err});
console.error('Error ensuring a valid scalar_token exists', err);
});
// The integrations manager will handle scalar auth for us.
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
room: this.props.room,
screen: `type_${widgetType}`,
integrationId: this.state.widgetId,
}, "mx_IntegrationsManager");
}
render() {

View file

@ -1,5 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -14,50 +15,124 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher';
import ScalarAuthClient from '../../../ScalarAuthClient';
const React = require('react');
const sdk = require('../../../index');
const MatrixClientPeg = require('../../../MatrixClientPeg');
const dis = require('../../../dispatcher');
export default class IntegrationsManager extends React.Component {
static propTypes = {
// the room object where the integrations manager should be opened in
room: PropTypes.object.isRequired,
module.exports = React.createClass({
displayName: 'IntegrationsManager',
// the screen name to open
screen: PropTypes.string,
propTypes: {
src: React.PropTypes.string.isRequired, // the source of the integration manager being embedded
onFinished: React.PropTypes.func.isRequired, // callback when the lightbox is dismissed
},
// the integration ID to open
integrationId: PropTypes.string,
// XXX: keyboard shortcuts for managing dialogs should be done by the modal
// dialog base class somehow, surely...
componentDidMount: function() {
// callback when the manager is dismissed
onFinished: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
loading: true,
configured: ScalarAuthClient.isPossible(),
connected: false, // true if a `src` is set and able to be connected to
src: null, // string for where to connect to
};
}
componentWillMount() {
if (!this.state.configured) return;
const scalarClient = new ScalarAuthClient();
scalarClient.connect().then(() => {
const hasCredentials = scalarClient.hasCredentials();
if (!hasCredentials) {
this.setState({
connected: false,
loading: false,
});
} else {
const src = scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
this.props.screen,
this.props.integrationId,
);
this.setState({
loading: false,
connected: true,
src: src,
});
}
}).catch(err => {
console.error(err);
this.setState({
loading: false,
connected: false,
});
});
}
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
document.addEventListener("keydown", this.onKeyDown);
},
}
componentWillUnmount: function() {
componentWillUnmount() {
dis.unregister(this.dispatcherRef);
document.removeEventListener("keydown", this.onKeyDown);
},
}
onKeyDown: function(ev) {
if (ev.keyCode == 27) { // escape
onKeyDown = (ev) => {
if (ev.keyCode === 27) { // escape
ev.stopPropagation();
ev.preventDefault();
this.props.onFinished();
}
},
};
onAction: function(payload) {
onAction = (payload) => {
if (payload.action === 'close_scalar') {
this.props.onFinished();
}
},
};
render: function() {
return (
<iframe src={ this.props.src }></iframe>
);
},
});
render() {
if (!this.state.configured) {
return (
<div className='mx_IntegrationsManager_error'>
<h3>{_t("No integrations server configured")}</h3>
<p>{_t("This Riot instance does not have an integrations server configured.")}</p>
</div>
);
}
if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
return (
<div className='mx_IntegrationsManager_loading'>
<h3>{_t("Connecting to integrations server...")}</h3>
<Spinner />
</div>
);
}
if (!this.state.connected) {
return (
<div className='mx_IntegrationsManager_error'>
<h3>{_t("Cannot connect to integrations server")}</h3>
<p>{_t("The integrations server is offline or it cannot reach your homeserver.")}</p>
</div>
);
}
return <iframe src={this.state.src}></iframe>;
}
}

View file

@ -29,48 +29,64 @@ export default class VoiceUserSettingsTab extends React.Component {
super();
this.state = {
mediaDevices: null,
mediaDevices: false,
activeAudioOutput: null,
activeAudioInput: null,
activeVideoInput: null,
};
}
componentWillMount(): void {
this._refreshMediaDevices();
async componentDidMount() {
const canSeeDeviceLabels = await CallMediaHandler.hasAnyLabeledDevices();
if (canSeeDeviceLabels) {
this._refreshMediaDevices();
}
}
_refreshMediaDevices = async (stream) => {
if (stream) {
// kill stream so that we don't leave it lingering around with webcam enabled etc
// as here we called gUM to ask user for permission to their device names only
stream.getTracks().forEach((track) => track.stop());
}
this.setState({
mediaDevices: await CallMediaHandler.getDevices(),
activeAudioOutput: CallMediaHandler.getAudioOutput(),
activeAudioInput: CallMediaHandler.getAudioInput(),
activeVideoInput: CallMediaHandler.getVideoInput(),
});
if (stream) {
// kill stream (after we've enumerated the devices, otherwise we'd get empty labels again)
// so that we don't leave it lingering around with webcam enabled etc
// as here we called gUM to ask user for permission to their device names only
stream.getTracks().forEach((track) => track.stop());
}
};
_requestMediaPermissions = () => {
const getUserMedia = (
window.navigator.getUserMedia || window.navigator.webkitGetUserMedia || window.navigator.mozGetUserMedia
);
if (getUserMedia) {
return getUserMedia.apply(window.navigator, [
{ video: true, audio: true },
this._refreshMediaDevices,
function() {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('No media permissions', '', ErrorDialog, {
title: _t('No media permissions'),
description: _t('You may need to manually permit Riot to access your microphone/webcam'),
});
},
]);
_requestMediaPermissions = async () => {
let constraints;
let stream;
let error;
try {
constraints = {video: true, audio: true};
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
// user likely doesn't have a webcam,
// we should still allow to select a microphone
if (err.name === "NotFoundError") {
constraints = { audio: true };
try {
stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
error = err;
}
} else {
error = err;
}
}
if (error) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('No media permissions', '', ErrorDialog, {
title: _t('No media permissions'),
description: _t('You may need to manually permit Riot to access your microphone/webcam'),
});
} else {
this._refreshMediaDevices(stream);
}
};
@ -100,7 +116,9 @@ export default class VoiceUserSettingsTab extends React.Component {
};
_renderDeviceOptions(devices, category) {
return devices.map((d) => <option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>);
return devices.map((d) => {
return (<option key={`${category}-${d.deviceId}`} value={d.deviceId}>{d.label}</option>);
});
}
render() {

View file

@ -31,7 +31,7 @@ export default class VerificationCancelled extends React.Component {
"The other party cancelled the verification.",
)}</p>
<DialogButtons
primaryButton={_t('Cancel')}
primaryButton={_t('OK')}
hasCancel={false}
onPrimaryButtonClick={this.props.onDone}
/>

View file

@ -30,12 +30,15 @@ import {getAddressType} from "./UserAddress";
* @param {object=} opts parameters for creating the room
* @param {string=} opts.dmUserId If specified, make this a DM room for this user and invite them
* @param {object=} opts.createOpts set of options to pass to createRoom call.
* @param {bool=} opts.spinner True to show a modal spinner while the room is created.
* Default: True
*
* @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed.
*/
function createRoom(opts) {
opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const Loader = sdk.getComponent("elements.Spinner");
@ -87,11 +90,12 @@ function createRoom(opts) {
},
];
const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
let modal;
if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
let roomId;
return client.createRoom(createOpts).finally(function() {
modal.close();
if (modal) modal.close();
}).then(function(res) {
roomId = res.room_id;
if (opts.dmUserId) {

View file

@ -15,22 +15,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {UserPillPart, RoomPillPart, PlainPart} from "./parts";
export default class AutocompleteWrapperModel {
constructor(updateCallback, getAutocompleterComponent, updateQuery, room, client) {
constructor(updateCallback, getAutocompleterComponent, updateQuery, partCreator) {
this._updateCallback = updateCallback;
this._getAutocompleterComponent = getAutocompleterComponent;
this._updateQuery = updateQuery;
this._partCreator = partCreator;
this._query = null;
this._room = room;
this._client = client;
}
onEscape(e) {
this._getAutocompleterComponent().onEscape(e);
this._updateCallback({
replacePart: new PlainPart(this._queryPart.text),
replacePart: this._partCreator.plain(this._queryPart.text),
caretOffset: this._queryOffset,
close: true,
});
@ -93,21 +90,22 @@ export default class AutocompleteWrapperModel {
}
_partForCompletion(completion) {
const firstChr = completion.completionId && completion.completionId[0];
const {completionId} = completion;
const text = completion.completion;
const firstChr = completionId && completionId[0];
switch (firstChr) {
case "@": {
const displayName = completion.completion;
const userId = completion.completionId;
const member = this._room.getMember(userId);
return new UserPillPart(userId, displayName, member);
}
case "#": {
const displayAlias = completion.completionId;
return new RoomPillPart(displayAlias, this._client);
if (completionId === "@room") {
return this._partCreator.atRoomPill(completionId);
} else {
return this._partCreator.userPill(text, completionId);
}
}
case "#":
return this._partCreator.roomPill(completionId);
// also used for emoji completion
default:
return new PlainPart(completion.completion);
return this._partCreator.plain(text);
}
}
}

View file

@ -15,50 +15,104 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {needsCaretNodeBefore, needsCaretNodeAfter} from "./render";
export function setCaretPosition(editor, model, caretPosition) {
const sel = document.getSelection();
sel.removeAllRanges();
const range = document.createRange();
const {offset, lineIndex, nodeIndex} = getLineAndNodePosition(model, caretPosition);
const lineNode = editor.childNodes[lineIndex];
let focusNode;
// empty line with just a <br>
if (nodeIndex === -1) {
focusNode = lineNode;
} else {
focusNode = lineNode.childNodes[nodeIndex];
// make sure we have a text node
if (focusNode.nodeType === Node.ELEMENT_NODE && focusNode.firstChild) {
focusNode = focusNode.firstChild;
}
}
range.setStart(focusNode, offset);
range.collapse(true);
sel.addRange(range);
}
function getLineAndNodePosition(model, caretPosition) {
const {parts} = model;
const {index} = caretPosition;
const partIndex = caretPosition.index;
const lineResult = findNodeInLineForPart(parts, partIndex);
const {lineIndex} = lineResult;
let {nodeIndex} = lineResult;
let {offset} = caretPosition;
// we're at an empty line between a newline part
// and another newline part or end/start of parts.
// set offset to 0 so it gets set to the <br> inside the line container
if (nodeIndex === -1) {
offset = 0;
} else {
// move caret out of uneditable part (into caret node, or empty line br) if needed
({nodeIndex, offset} = moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset));
}
return {lineIndex, nodeIndex, offset};
}
function findNodeInLineForPart(parts, partIndex) {
let lineIndex = 0;
let nodeIndex = -1;
for (let i = 0; i <= index; ++i) {
let prevPart = null;
// go through to parts up till (and including) the index
// to find newline parts
for (let i = 0; i <= partIndex; ++i) {
const part = parts[i];
if (part && part.type === "newline") {
if (i < index) {
lineIndex += 1;
nodeIndex = -1;
} else {
// if index points at a newline part,
// put the caret at the end of the previous part
// so it stays on the same line
const prevPart = parts[i - 1];
offset = prevPart ? prevPart.text.length : 0;
if (part.type === "newline") {
lineIndex += 1;
nodeIndex = -1;
prevPart = null;
} else {
nodeIndex += 1;
if (needsCaretNodeBefore(part, prevPart)) {
nodeIndex += 1;
}
// only jump over caret node if we're not at our destination node already,
// as we'll assume in moveOutOfUneditablePart that nodeIndex
// refers to the node corresponding to the part,
// and not an adjacent caret node
if (i < partIndex) {
const nextPart = parts[i + 1];
const isLastOfLine = !nextPart || nextPart.type === "newline";
if (needsCaretNodeAfter(part, isLastOfLine)) {
nodeIndex += 1;
}
}
prevPart = part;
}
}
return {lineIndex, nodeIndex};
}
function moveOutOfUneditablePart(parts, partIndex, nodeIndex, offset) {
// move caret before or after uneditable part
const part = parts[partIndex];
if (part && !part.canEdit) {
if (offset === 0) {
nodeIndex -= 1;
const prevPart = parts[partIndex - 1];
// if the previous node is a caret node, it's empty
// so the offset can stay at 0
// only when it's not, we need to set the offset
// at the end of the node
if (!needsCaretNodeBefore(part, prevPart)) {
offset = prevPart.text.length;
}
} else {
nodeIndex += 1;
offset = 0;
}
}
let focusNode;
const lineNode = editor.childNodes[lineIndex];
if (lineNode) {
focusNode = lineNode.childNodes[nodeIndex];
if (!focusNode) {
focusNode = lineNode;
} else if (focusNode.nodeType === Node.ELEMENT_NODE) {
focusNode = focusNode.childNodes[0];
}
}
// node not found, set caret at end
if (!focusNode) {
range.selectNodeContents(editor);
range.collapse(false);
} else {
// make sure we have a text node
range.setStart(focusNode, offset);
range.collapse(true);
}
sel.addRange(range);
return {nodeIndex, offset};
}

View file

@ -16,73 +16,92 @@ limitations under the License.
*/
import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts";
import { walkDOMDepthFirst } from "./dom";
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
function parseLink(a, room, client) {
function parseAtRoomMentions(text, partCreator) {
const ATROOM = "@room";
const parts = [];
text.split(ATROOM).forEach((textPart, i, arr) => {
if (textPart.length) {
parts.push(partCreator.plain(textPart));
}
// it's safe to never append @room after the last textPart
// as split will report an empty string at the end if
// `text` ended in @room.
const isLast = i === arr.length - 1;
if (!isLast) {
parts.push(partCreator.atRoomPill(ATROOM));
}
});
return parts;
}
function parseLink(a, partCreator) {
const {href} = a;
const pillMatch = REGEX_MATRIXTO.exec(href) || [];
const resourceId = pillMatch[1]; // The room/user ID
const prefix = pillMatch[2]; // The first character of prefix
switch (prefix) {
case "@":
return new UserPillPart(
resourceId,
a.textContent,
room.getMember(resourceId),
);
return partCreator.userPill(a.textContent, resourceId);
case "#":
return new RoomPillPart(resourceId, client);
return partCreator.roomPill(resourceId);
default: {
if (href === a.textContent) {
return new PlainPart(a.textContent);
return partCreator.plain(a.textContent);
} else {
return new PlainPart(`[${a.textContent}](${href})`);
return partCreator.plain(`[${a.textContent}](${href})`);
}
}
}
}
function parseCodeBlock(n) {
function parseCodeBlock(n, partCreator) {
const parts = [];
const preLines = ("```\n" + n.textContent + "```").split("\n");
preLines.forEach((l, i) => {
parts.push(new PlainPart(l));
parts.push(partCreator.plain(l));
if (i < preLines.length - 1) {
parts.push(new NewlinePart("\n"));
parts.push(partCreator.newline());
}
});
return parts;
}
function parseElement(n, room, client) {
function parseElement(n, partCreator, state) {
switch (n.nodeName) {
case "A":
return parseLink(n, room, client);
return parseLink(n, partCreator);
case "BR":
return new NewlinePart("\n");
return partCreator.newline();
case "EM":
return new PlainPart(`*${n.textContent}*`);
return partCreator.plain(`*${n.textContent}*`);
case "STRONG":
return new PlainPart(`**${n.textContent}**`);
return partCreator.plain(`**${n.textContent}**`);
case "PRE":
return parseCodeBlock(n);
return parseCodeBlock(n, partCreator);
case "CODE":
return new PlainPart(`\`${n.textContent}\``);
return partCreator.plain(`\`${n.textContent}\``);
case "DEL":
return new PlainPart(`<del>${n.textContent}</del>`);
case "LI":
return partCreator.plain(`<del>${n.textContent}</del>`);
case "LI": {
const indent = " ".repeat(state.listDepth - 1);
if (n.parentElement.nodeName === "OL") {
return new PlainPart(` 1. `);
return partCreator.plain(`${indent}1. `);
} else {
return new PlainPart(` - `);
return partCreator.plain(`${indent}- `);
}
}
case "OL":
case "UL":
state.listDepth = (state.listDepth || 0) + 1;
// es-lint-disable-next-line no-fallthrough
default:
// don't textify block nodes we'll decend into
if (!checkDecendInto(n)) {
return new PlainPart(n.textContent);
return partCreator.plain(n.textContent);
}
}
}
@ -125,22 +144,22 @@ function checkIgnored(n) {
return true;
}
function prefixQuoteLines(isFirstNode, parts) {
function prefixQuoteLines(isFirstNode, parts, partCreator) {
const PREFIX = "> ";
// a newline (to append a > to) wouldn't be added to parts for the first line
// if there was no content before the BLOCKQUOTE, so handle that
if (isFirstNode) {
parts.splice(0, 0, new PlainPart(PREFIX));
parts.splice(0, 0, partCreator.plain(PREFIX));
}
for (let i = 0; i < parts.length; i += 1) {
if (parts[i].type === "newline") {
parts.splice(i + 1, 0, new PlainPart(PREFIX));
parts.splice(i + 1, 0, partCreator.plain(PREFIX));
i += 1;
}
}
}
function parseHtmlMessage(html, room, client) {
function parseHtmlMessage(html, partCreator) {
// no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine
@ -148,6 +167,7 @@ function parseHtmlMessage(html, room, client) {
const parts = [];
let lastNode;
let inQuote = false;
const state = {};
function onNodeEnter(n) {
if (checkIgnored(n)) {
@ -159,13 +179,13 @@ function parseHtmlMessage(html, room, client) {
const newParts = [];
if (lastNode && (checkBlockNode(lastNode) || checkBlockNode(n))) {
newParts.push(new NewlinePart("\n"));
newParts.push(partCreator.newline());
}
if (n.nodeType === Node.TEXT_NODE) {
newParts.push(new PlainPart(n.nodeValue));
newParts.push(...parseAtRoomMentions(n.nodeValue, partCreator));
} else if (n.nodeType === Node.ELEMENT_NODE) {
const parseResult = parseElement(n, room, client);
const parseResult = parseElement(n, partCreator, state);
if (parseResult) {
if (Array.isArray(parseResult)) {
newParts.push(...parseResult);
@ -177,14 +197,14 @@ function parseHtmlMessage(html, room, client) {
if (newParts.length && inQuote) {
const isFirstPart = parts.length === 0;
prefixQuoteLines(isFirstPart, newParts);
prefixQuoteLines(isFirstPart, newParts, partCreator);
}
parts.push(...newParts);
// extra newline after quote, only if there something behind it...
if (lastNode && lastNode.nodeName === "BLOCKQUOTE") {
parts.push(new NewlinePart("\n"));
parts.push(partCreator.newline());
}
lastNode = null;
return checkDecendInto(n);
@ -194,8 +214,14 @@ function parseHtmlMessage(html, room, client) {
if (checkIgnored(n)) {
return;
}
if (n.nodeName === "BLOCKQUOTE") {
inQuote = false;
switch (n.nodeName) {
case "BLOCKQUOTE":
inQuote = false;
break;
case "OL":
case "UL":
state.listDepth -= 1;
break;
}
lastNode = n;
}
@ -205,23 +231,25 @@ function parseHtmlMessage(html, room, client) {
return parts;
}
export function parseEvent(event, room, client) {
export function parseEvent(event, partCreator) {
const content = event.getContent();
let parts;
if (content.format === "org.matrix.custom.html") {
return parseHtmlMessage(content.formatted_body || "", room, client);
parts = parseHtmlMessage(content.formatted_body || "", partCreator);
} else {
const body = content.body || "";
const lines = body.split("\n");
const parts = lines.reduce((parts, line, i) => {
parts = lines.reduce((parts, line, i) => {
const isLast = i === lines.length - 1;
const text = new PlainPart(line);
const newLine = !isLast && new NewlinePart("\n");
if (newLine) {
return parts.concat(text, newLine);
} else {
return parts.concat(text);
const newParts = parseAtRoomMentions(line, partCreator);
if (!isLast) {
newParts.push(partCreator.newline());
}
return parts.concat(newParts);
}, []);
return parts;
}
if (content.msgtype === "m.emote") {
parts.unshift(partCreator.plain("/me "));
}
return parts;
}

View file

@ -15,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {CARET_NODE_CHAR, isCaretNode} from "./render";
export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback) {
let node = rootNode.firstChild;
while (node && node !== rootNode) {
@ -38,27 +40,54 @@ export function walkDOMDepthFirst(rootNode, enterNodeCallback, leaveNodeCallback
}
export function getCaretOffsetAndText(editor, sel) {
let {focusNode} = sel;
const {focusOffset} = sel;
let caretOffset = focusOffset;
let {focusNode, focusOffset} = sel;
// sometimes focusNode is an element, and then focusOffset means
// the index of a child element ... - 1 🤷
if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) {
focusNode = focusNode.childNodes[focusOffset - 1];
focusOffset = focusNode.textContent.length;
}
const {text, focusNodeOffset} = getTextAndFocusNodeOffset(editor, focusNode, focusOffset);
const caret = getCaret(focusNode, focusNodeOffset, focusOffset);
return {caret, text};
}
// gets the caret position details, ignoring and adjusting to
// the ZWS if you're typing in a caret node
function getCaret(focusNode, focusNodeOffset, focusOffset) {
let atNodeEnd = focusOffset === focusNode.textContent.length;
if (focusNode.nodeType === Node.TEXT_NODE && isCaretNode(focusNode.parentElement)) {
const zwsIdx = focusNode.nodeValue.indexOf(CARET_NODE_CHAR);
if (zwsIdx !== -1 && zwsIdx < focusOffset) {
focusOffset -= 1;
}
// if typing in a caret node, you're either typing before or after the ZWS.
// In both cases, you should be considered at node end because the ZWS is
// not included in the text here, and once the model is updated and rerendered,
// that caret node will be removed.
atNodeEnd = true;
}
return {offset: focusNodeOffset + focusOffset, atNodeEnd};
}
// gets the text of the editor as a string,
// and the offset in characters where the focusNode starts in that string
// all ZWS from caret nodes are filtered out
function getTextAndFocusNodeOffset(editor, focusNode, focusOffset) {
let focusNodeOffset = 0;
let foundCaret = false;
let text = "";
if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) {
focusNode = focusNode.childNodes[focusOffset - 1];
caretOffset = focusNode.textContent.length;
}
function enterNodeCallback(node) {
const nodeText = node.nodeType === Node.TEXT_NODE && node.nodeValue;
if (!foundCaret) {
if (node === focusNode) {
foundCaret = true;
}
}
const nodeText = node.nodeType === Node.TEXT_NODE && getTextNodeValue(node);
if (nodeText) {
if (!foundCaret) {
caretOffset += nodeText.length;
focusNodeOffset += nodeText.length;
}
text += nodeText;
}
@ -73,14 +102,30 @@ export function getCaretOffsetAndText(editor, sel) {
if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") {
text += "\n";
if (!foundCaret) {
caretOffset += 1;
focusNodeOffset += 1;
}
}
}
walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback);
const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length;
const caret = {atNodeEnd, offset: caretOffset};
return {caret, text};
return {text, focusNodeOffset};
}
// get text value of text node, ignoring ZWS if it's a caret node
function getTextNodeValue(node) {
const nodeText = node.nodeValue;
// filter out ZWS for caret nodes
if (isCaretNode(node.parentElement)) {
// typed in the caret node, so there is now something more in it than the ZWS
// so filter out the ZWS, and take the typed text into account
if (nodeText.length !== 1) {
return nodeText.replace(CARET_NODE_CHAR, "");
} else {
// only contains ZWS, which is ignored, so return emtpy string
return "";
}
} else {
return nodeText;
}
}

View file

@ -27,6 +27,10 @@ export default class EditorModel {
this._updateCallback = updateCallback;
}
clone() {
return new EditorModel(this._parts, this._partCreator, this._updateCallback);
}
_insertPart(index, part) {
this._parts.splice(index, 0, part);
if (this._activePartIdx >= index) {
@ -91,7 +95,7 @@ export default class EditorModel {
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0;
if (diff.removed) {
removedOffsetDecrease = this._removeText(position, diff.removed.length);
removedOffsetDecrease = this.removeText(position, diff.removed.length);
}
let addedLen = 0;
if (diff.added) {
@ -99,8 +103,7 @@ export default class EditorModel {
}
this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
let newPosition = this.positionForOffset(caretOffset, true);
newPosition = newPosition.skipUneditableParts(this._parts);
const newPosition = this.positionForOffset(caretOffset, true);
this._setActivePart(newPosition);
this._updateCallback(newPosition);
}
@ -136,10 +139,9 @@ export default class EditorModel {
let pos;
if (replacePart) {
this._replacePart(this._autoCompletePartIdx, replacePart);
let index = this._autoCompletePartIdx;
const index = this._autoCompletePartIdx;
if (caretOffset === undefined) {
caretOffset = 0;
index += 1;
caretOffset = replacePart.text.length;
}
pos = new DocumentPosition(index, caretOffset);
}
@ -154,11 +156,11 @@ export default class EditorModel {
}
_mergeAdjacentParts(docPos) {
let prevPart = this._parts[0];
for (let i = 1; i < this._parts.length; ++i) {
let prevPart;
for (let i = 0; i < this._parts.length; ++i) {
let part = this._parts[i];
const isEmpty = !part.text.length;
const isMerged = !isEmpty && prevPart.merge(part);
const isMerged = !isEmpty && prevPart && prevPart.merge(part);
if (isEmpty || isMerged) {
// remove empty or merged part
part = prevPart;
@ -177,7 +179,7 @@ export default class EditorModel {
* @return {Number} how many characters before pos were also removed,
* usually because of non-editable parts that can only be removed in their entirety.
*/
_removeText(pos, len) {
removeText(pos, len) {
let {index, offset} = pos;
let removedOffsetDecrease = 0;
while (len > 0) {
@ -279,13 +281,4 @@ class DocumentPosition {
get offset() {
return this._offset;
}
skipUneditableParts(parts) {
const part = parts[this.index];
if (part && !part.canEdit) {
return new DocumentPosition(this.index + 1, 0);
} else {
return this;
}
}
}

View file

@ -107,7 +107,7 @@ class BasePart {
}
}
export class PlainPart extends BasePart {
class PlainPart extends BasePart {
acceptsInsertion(chr) {
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
}
@ -199,7 +199,7 @@ class PillPart extends BasePart {
}
}
export class NewlinePart extends BasePart {
class NewlinePart extends BasePart {
acceptsInsertion(chr, i) {
return (this.text.length + i) === 0 && chr === "\n";
}
@ -235,20 +235,10 @@ export class NewlinePart extends BasePart {
}
}
export class RoomPillPart extends PillPart {
constructor(displayAlias, client) {
class RoomPillPart extends PillPart {
constructor(displayAlias, room) {
super(displayAlias, displayAlias);
this._room = this._findRoomByAlias(displayAlias, client);
}
_findRoomByAlias(alias, client) {
if (alias[0] === '#') {
return client.getRooms().find((r) => {
return r.getAliases().includes(alias);
});
} else {
return client.getRoom(alias);
}
this._room = room;
}
setAvatar(node) {
@ -270,7 +260,13 @@ export class RoomPillPart extends PillPart {
}
}
export class UserPillPart extends PillPart {
class AtRoomPillPart extends RoomPillPart {
get type() {
return "at-room-pill";
}
}
class UserPillPart extends PillPart {
constructor(userId, displayName, member) {
super(userId, displayName);
this._member = member;
@ -311,7 +307,7 @@ export class UserPillPart extends PillPart {
}
export class PillCandidatePart extends PlainPart {
class PillCandidatePart extends PlainPart {
constructor(text, autoCompleteCreator) {
super(text);
this._autoCompleteCreator = autoCompleteCreator;
@ -351,8 +347,7 @@ export class PartCreator {
updateCallback,
getAutocompleterComponent,
updateQuery,
room,
client,
this,
);
};
}
@ -362,7 +357,7 @@ export class PartCreator {
case "#":
case "@":
case ":":
return new PillCandidatePart("", this._autoCompleteCreator);
return this.pillCandidate("");
case "\n":
return new NewlinePart();
default:
@ -371,24 +366,57 @@ export class PartCreator {
}
createDefaultPart(text) {
return new PlainPart(text);
return this.plain(text);
}
deserializePart(part) {
switch (part.type) {
case "plain":
return new PlainPart(part.text);
return this.plain(part.text);
case "newline":
return new NewlinePart(part.text);
return this.newline();
case "at-room-pill":
return this.atRoomPill(part.text);
case "pill-candidate":
return new PillCandidatePart(part.text, this._autoCompleteCreator);
return this.pillCandidate(part.text);
case "room-pill":
return new RoomPillPart(part.text, this._client);
case "user-pill": {
const member = this._room.getMember(part.userId);
return new UserPillPart(part.userId, part.text, member);
}
return this.roomPill(part.text);
case "user-pill":
return this.userPill(part.text, part.userId);
}
}
plain(text) {
return new PlainPart(text);
}
newline() {
return new NewlinePart("\n");
}
pillCandidate(text) {
return new PillCandidatePart(text, this._autoCompleteCreator);
}
roomPill(alias) {
let room;
if (alias[0] === '#') {
room = this._client.getRooms().find((r) => {
return r.getAliases().includes(alias);
});
} else {
room = this._client.getRoom(alias);
}
return new RoomPillPart(alias, room);
}
atRoomPill(text) {
return new AtRoomPillPart(text, this._room);
}
userPill(displayName, userId) {
const member = this._room.getMember(userId);
return new UserPillPart(userId, displayName, member);
}
}

View file

@ -15,6 +15,137 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export function needsCaretNodeBefore(part, prevPart) {
const isFirst = !prevPart || prevPart.type === "newline";
return !part.canEdit && (isFirst || !prevPart.canEdit);
}
export function needsCaretNodeAfter(part, isLastOfLine) {
return !part.canEdit && isLastOfLine;
}
function insertAfter(node, nodeToInsert) {
const next = node.nextSibling;
if (next) {
node.parentElement.insertBefore(nodeToInsert, next);
} else {
node.parentElement.appendChild(nodeToInsert);
}
}
// Use a BOM marker for caret nodes.
// On a first test, they seem to be filtered out when copying text out of the editor,
// but this could be platform dependent.
// As a precautionary measure, I chose the character that slate also uses.
export const CARET_NODE_CHAR = "\ufeff";
// a caret node is a node that allows the caret to be placed
// where otherwise it wouldn't be possible
// (e.g. next to a pill span without adjacent text node)
function createCaretNode() {
const span = document.createElement("span");
span.className = "caretNode";
span.appendChild(document.createTextNode(CARET_NODE_CHAR));
return span;
}
function updateCaretNode(node) {
// ensure the caret node contains only a zero-width space
if (node.textContent !== CARET_NODE_CHAR) {
node.textContent = CARET_NODE_CHAR;
}
}
export function isCaretNode(node) {
return node && node.tagName === "SPAN" && node.className === "caretNode";
}
function removeNextSiblings(node) {
if (!node) {
return;
}
node = node.nextSibling;
while (node) {
const removeNode = node;
node = node.nextSibling;
removeNode.remove();
}
}
function removeChildren(parent) {
const firstChild = parent.firstChild;
if (firstChild) {
removeNextSiblings(firstChild);
firstChild.remove();
}
}
function reconcileLine(lineContainer, parts) {
let currentNode;
let prevPart;
const lastPart = parts[parts.length - 1];
for (const part of parts) {
const isFirst = !prevPart;
currentNode = isFirst ? lineContainer.firstChild : currentNode.nextSibling;
if (needsCaretNodeBefore(part, prevPart)) {
if (isCaretNode(currentNode)) {
updateCaretNode(currentNode);
currentNode = currentNode.nextSibling;
} else {
lineContainer.insertBefore(createCaretNode(), currentNode);
}
}
// remove nodes until matching current part
while (currentNode && !part.canUpdateDOMNode(currentNode)) {
const nextNode = currentNode.nextSibling;
lineContainer.removeChild(currentNode);
currentNode = nextNode;
}
// update or insert node for current part
if (currentNode && part) {
part.updateDOMNode(currentNode);
} else if (part) {
currentNode = part.toDOMNode();
// hooks up nextSibling for next iteration
lineContainer.appendChild(currentNode);
}
if (needsCaretNodeAfter(part, part === lastPart)) {
if (isCaretNode(currentNode.nextSibling)) {
currentNode = currentNode.nextSibling;
updateCaretNode(currentNode);
} else {
const caretNode = createCaretNode();
insertAfter(currentNode, caretNode);
currentNode = caretNode;
}
}
prevPart = part;
}
removeNextSiblings(currentNode);
}
function reconcileEmptyLine(lineContainer) {
// empty div needs to have a BR in it to give it height
let foundBR = false;
let partNode = lineContainer.firstChild;
while (partNode) {
const nextNode = partNode.nextSibling;
if (!foundBR && partNode.tagName === "BR") {
foundBR = true;
} else {
partNode.remove();
}
partNode = nextNode;
}
if (!foundBR) {
lineContainer.appendChild(document.createElement("br"));
}
}
export function renderModel(editor, model) {
const lines = model.parts.reduce((lines, part) => {
if (part.type === "newline") {
@ -25,8 +156,9 @@ export function renderModel(editor, model) {
}
return lines;
}, [[]]);
// TODO: refactor this code, DRY it
lines.forEach((parts, i) => {
// find first (and remove anything else) div without className
// (as browsers insert these in contenteditable) line container
let lineContainer = editor.childNodes[i];
while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) {
editor.removeChild(lineContainer);
@ -38,46 +170,14 @@ export function renderModel(editor, model) {
}
if (parts.length) {
parts.forEach((part, j) => {
let partNode = lineContainer.childNodes[j];
while (partNode && !part.canUpdateDOMNode(partNode)) {
lineContainer.removeChild(partNode);
partNode = lineContainer.childNodes[j];
}
if (partNode && part) {
part.updateDOMNode(partNode);
} else if (part) {
lineContainer.appendChild(part.toDOMNode());
}
});
let surplusElementCount = Math.max(0, lineContainer.childNodes.length - parts.length);
while (surplusElementCount) {
lineContainer.removeChild(lineContainer.lastChild);
--surplusElementCount;
}
reconcileLine(lineContainer, parts);
} else {
// empty div needs to have a BR in it to give it height
let foundBR = false;
let partNode = lineContainer.firstChild;
while (partNode) {
const nextNode = partNode.nextSibling;
if (!foundBR && partNode.tagName === "BR") {
foundBR = true;
} else {
lineContainer.removeChild(partNode);
}
partNode = nextNode;
}
if (!foundBR) {
lineContainer.appendChild(document.createElement("br"));
}
}
let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length);
while (surplusElementCount) {
editor.removeChild(editor.lastChild);
--surplusElementCount;
reconcileEmptyLine(lineContainer);
}
});
if (lines.length) {
removeNextSiblings(editor.children[lines.length]);
} else {
removeChildren(editor);
}
}

View file

@ -24,6 +24,7 @@ export function mdSerialize(model) {
return html + "\n";
case "plain":
case "pill-candidate":
case "at-room-pill":
return html + part.text;
case "room-pill":
case "user-pill":
@ -47,6 +48,7 @@ export function textSerialize(model) {
return text + "\n";
case "plain":
case "pill-candidate":
case "at-room-pill":
return text + part.text;
case "room-pill":
case "user-pill":
@ -58,13 +60,11 @@ export function textSerialize(model) {
export function requiresHtml(model) {
return model.parts.some(part => {
switch (part.type) {
case "newline":
case "plain":
case "pill-candidate":
return false;
case "room-pill":
case "user-pill":
return true;
default:
return false;
}
});
}

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