diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 42818244b3..430546d281 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -2,9 +2,7 @@ src/autocomplete/AutocompleteProvider.js src/autocomplete/Autocompleter.js -src/autocomplete/EmojiProvider.js src/autocomplete/UserProvider.js -src/CallHandler.js src/component-index.js src/components/structures/BottomLeftMenu.js src/components/structures/CompatibilityPage.js @@ -13,27 +11,22 @@ src/components/structures/HomePage.js src/components/structures/LeftPanel.js src/components/structures/LoggedInView.js src/components/structures/login/ForgotPassword.js -src/components/structures/login/Login.js -src/components/structures/login/Registration.js src/components/structures/LoginBox.js src/components/structures/MessagePanel.js src/components/structures/NotificationPanel.js src/components/structures/RoomDirectory.js src/components/structures/RoomStatusBar.js -src/components/structures/RoomSubList.js src/components/structures/RoomView.js src/components/structures/ScrollPanel.js src/components/structures/SearchBox.js src/components/structures/TimelinePanel.js src/components/structures/UploadBar.js +src/components/structures/UserSettings.js src/components/structures/ViewSource.js src/components/views/avatars/BaseAvatar.js -src/components/views/avatars/GroupAvatar.js src/components/views/avatars/MemberAvatar.js src/components/views/create_room/RoomAlias.js -src/components/views/dialogs/BugReportDialog.js src/components/views/dialogs/ChangelogDialog.js -src/components/views/dialogs/ChatCreateOrReuseDialog.js src/components/views/dialogs/DeactivateAccountDialog.js src/components/views/dialogs/SetPasswordDialog.js src/components/views/dialogs/UnknownDeviceDialog.js @@ -41,12 +34,12 @@ src/components/views/directory/NetworkDropdown.js src/components/views/elements/AddressSelector.js src/components/views/elements/DeviceVerifyButtons.js src/components/views/elements/DirectorySearchBox.js -src/components/views/elements/EditableText.js src/components/views/elements/ImageView.js src/components/views/elements/InlineSpinner.js src/components/views/elements/MemberEventListSummary.js src/components/views/elements/Spinner.js src/components/views/elements/TintableSvg.js +src/components/views/elements/UserInfo.js src/components/views/elements/UserSelector.js src/components/views/globals/MatrixToolbar.js src/components/views/globals/NewVersionBar.js @@ -65,7 +58,6 @@ src/components/views/room_settings/UrlPreviewSettings.js src/components/views/rooms/Autocomplete.js src/components/views/rooms/AuxPanel.js src/components/views/rooms/EntityTile.js -src/components/views/rooms/EventTile.js src/components/views/rooms/LinkPreviewWidget.js src/components/views/rooms/MemberDeviceInfo.js src/components/views/rooms/MemberInfo.js @@ -73,12 +65,11 @@ src/components/views/rooms/MemberList.js src/components/views/rooms/MemberTile.js src/components/views/rooms/MessageComposer.js src/components/views/rooms/MessageComposerInput.js +src/components/views/rooms/PinnedEventTile.js src/components/views/rooms/RoomDropTarget.js src/components/views/rooms/RoomList.js src/components/views/rooms/RoomPreviewBar.js src/components/views/rooms/RoomSettings.js -src/components/views/rooms/RoomTile.js -src/components/views/rooms/RoomTooltip.js src/components/views/rooms/SearchableEntityList.js src/components/views/rooms/SearchBar.js src/components/views/rooms/SearchResultTile.js @@ -86,12 +77,12 @@ src/components/views/rooms/TopUnreadMessagesBar.js src/components/views/rooms/UserTile.js src/components/views/settings/AddPhoneNumber.js src/components/views/settings/ChangeAvatar.js -src/components/views/settings/ChangeDisplayName.js src/components/views/settings/ChangePassword.js src/components/views/settings/DevicesPanel.js src/components/views/settings/IntegrationsManager.js src/components/views/settings/Notifications.js src/ContentMessages.js +src/GroupAddressPicker.js src/HtmlUtils.js src/ImageUtils.js src/languageHandler.js @@ -135,6 +126,7 @@ test/components/structures/TimelinePanel-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js test/components/views/login/RegistrationForm-test.js test/components/views/rooms/MessageComposerInput-test.js +test/components/views/rooms/RoomSettings-test.js test/mock-clock.js test/notifications/ContentRules-test.js test/notifications/PushRuleVectorState-test.js diff --git a/.eslintrc.js b/.eslintrc.js index bf423a1ad8..62d24ea707 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -95,6 +95,7 @@ module.exports = { "new-cap": ["warn"], "key-spacing": ["warn"], "prefer-const": ["warn"], + "arrow-parens": "off", // crashes currently: https://github.com/eslint/eslint/issues/6274 "generator-star-spacing": "off", diff --git a/CHANGELOG.md b/CHANGELOG.md index b161a9d908..5e35c20d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,307 @@ +Changes in [0.12.9](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.9) (2018-07-09) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.9-rc.2...v0.12.9) + + * No changes since rc.1 + +Changes in [0.12.9-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.9-rc.2) (2018-07-06) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.9-rc.1...v0.12.9-rc.2) + + * Implement aggregation by error type for tracked decryption failures + [\#2045](https://github.com/matrix-org/matrix-react-sdk/pull/2045) + * make new hiding of roomsublist behaviour opt-in + [\#2044](https://github.com/matrix-org/matrix-react-sdk/pull/2044) + * Implement aggregation by error type for tracked decryption failures + [\#2043](https://github.com/matrix-org/matrix-react-sdk/pull/2043) + * make new hiding of roomsublist behaviour opt-in + [\#2030](https://github.com/matrix-org/matrix-react-sdk/pull/2030) + +Changes in [0.12.9-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.9-rc.1) (2018-07-04) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.8...v0.12.9-rc.1) + + * Update from Weblate. + [\#2040](https://github.com/matrix-org/matrix-react-sdk/pull/2040) + * Import react as React in src/components/views/messages/MStickerBody.js + [\#2039](https://github.com/matrix-org/matrix-react-sdk/pull/2039) + * Import react as React in src/GroupAddressPicker.js + [\#2038](https://github.com/matrix-org/matrix-react-sdk/pull/2038) + * Give PersistedElement a key + [\#2036](https://github.com/matrix-org/matrix-react-sdk/pull/2036) + * Revert " make click to insert nick work on join/parts, /me's etc" + [\#2034](https://github.com/matrix-org/matrix-react-sdk/pull/2034) + * Track an event name when tracking a decryption failure + [\#2033](https://github.com/matrix-org/matrix-react-sdk/pull/2033) + * warn on self-mute + [\#1974](https://github.com/matrix-org/matrix-react-sdk/pull/1974) + * make click to insert nick work on join/parts, /me's etc + [\#1945](https://github.com/matrix-org/matrix-react-sdk/pull/1945) + * Fix layout bug introduced by #2025 + [\#2029](https://github.com/matrix-org/matrix-react-sdk/pull/2029) + * Fix room topics/names resetting when UserSetting re-renders + [\#2028](https://github.com/matrix-org/matrix-react-sdk/pull/2028) + * Improve tracking of UISIs + [\#2027](https://github.com/matrix-org/matrix-react-sdk/pull/2027) + * Replace share icons + [\#2026](https://github.com/matrix-org/matrix-react-sdk/pull/2026) + * Improve status bar errors (namely the consent error) + [\#2025](https://github.com/matrix-org/matrix-react-sdk/pull/2025) + * Fix incorrectly positioned copy button on `
` blocks
+   [\#2023](https://github.com/matrix-org/matrix-react-sdk/pull/2023)
+ * Redact pathnames with origin `file://`
+   [\#2018](https://github.com/matrix-org/matrix-react-sdk/pull/2018)
+ * Update package-lock.json
+   [\#2022](https://github.com/matrix-org/matrix-react-sdk/pull/2022)
+ * on room sub list badge click goto first relevant room
+   [\#2021](https://github.com/matrix-org/matrix-react-sdk/pull/2021)
+ * improve linkifier AGAIN
+   [\#2020](https://github.com/matrix-org/matrix-react-sdk/pull/2020)
+ * fix historical section
+   [\#2016](https://github.com/matrix-org/matrix-react-sdk/pull/2016)
+ * Fix RoomSubList headers by re-commiting 1faecfd
+   [\#2014](https://github.com/matrix-org/matrix-react-sdk/pull/2014)
+ * don't fire share dialog when clicking timestamp of event,
+   [\#2017](https://github.com/matrix-org/matrix-react-sdk/pull/2017)
+ * Revert "affix copyButton so that it doesn't get scrolled horizontally"
+   [\#2013](https://github.com/matrix-org/matrix-react-sdk/pull/2013)
+ * when the user switches room, close room settings
+   [\#2019](https://github.com/matrix-org/matrix-react-sdk/pull/2019)
+ * Refactor widgets code
+   [\#2015](https://github.com/matrix-org/matrix-react-sdk/pull/2015)
+ * Login local errors for blank fields
+   [\#2009](https://github.com/matrix-org/matrix-react-sdk/pull/2009)
+ * Update lolex to 2.7.0
+   [\#1917](https://github.com/matrix-org/matrix-react-sdk/pull/1917)
+ * Improve Linkifier
+   [\#2011](https://github.com/matrix-org/matrix-react-sdk/pull/2011)
+ * use enum constants for EventStatus and correct isSent check
+   [\#2010](https://github.com/matrix-org/matrix-react-sdk/pull/2010)
+ * accent insensitive autocomplete
+   [\#2007](https://github.com/matrix-org/matrix-react-sdk/pull/2007)
+ * default to not showing url previews in e2ee rooms.
+   [\#2001](https://github.com/matrix-org/matrix-react-sdk/pull/2001)
+ * allow chaining right click contextmenus
+   [\#1999](https://github.com/matrix-org/matrix-react-sdk/pull/1999)
+ * hide empty roomsublists when filtering via search/tagpanel
+   [\#1954](https://github.com/matrix-org/matrix-react-sdk/pull/1954)
+ * prevent user,room,group autocomplete firing mid-word
+   [\#2012](https://github.com/matrix-org/matrix-react-sdk/pull/2012)
+ * fix instances of composer not getting/regaining focus
+   [\#2008](https://github.com/matrix-org/matrix-react-sdk/pull/2008)
+ * notif panel fixes
+   [\#2006](https://github.com/matrix-org/matrix-react-sdk/pull/2006)
+ * factor out conditional LanguageSelector as functional component
+   [\#2003](https://github.com/matrix-org/matrix-react-sdk/pull/2003)
+ * Autocomplete and Pillify Communities
+   [\#1993](https://github.com/matrix-org/matrix-react-sdk/pull/1993)
+ * Very basic Jitsi integration
+   [\#1971](https://github.com/matrix-org/matrix-react-sdk/pull/1971)
+ * add additional classes which protect the text from overflowing
+   [\#1994](https://github.com/matrix-org/matrix-react-sdk/pull/1994)
+ * Upload File confirmation modal steals focus, send it back to composer
+   [\#1992](https://github.com/matrix-org/matrix-react-sdk/pull/1992)
+ * delint MImageBody, fixes anonymous class and hyphenated style keys which
+   made react cry
+   [\#1991](https://github.com/matrix-org/matrix-react-sdk/pull/1991)
+ * allow using tab to navigate room list in a smarter way
+   [\#1977](https://github.com/matrix-org/matrix-react-sdk/pull/1977)
+ * fix no displayname usersettings
+   [\#1990](https://github.com/matrix-org/matrix-react-sdk/pull/1990)
+ * trigger TagTile context menu on right click
+   [\#1989](https://github.com/matrix-org/matrix-react-sdk/pull/1989)
+ * hide already chosen results from AddressPickerDialog
+   [\#2000](https://github.com/matrix-org/matrix-react-sdk/pull/2000)
+ * delint ChatCreateOrReuseDialog
+   [\#2002](https://github.com/matrix-org/matrix-react-sdk/pull/2002)
+ * fix set password & email flow possible to get stuck and onBlur murdering
+   your email
+   [\#1982](https://github.com/matrix-org/matrix-react-sdk/pull/1982)
+
+Changes in [0.12.8](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.8) (2018-06-29)
+=====================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.8-rc.2...v0.12.8)
+
+ * Revert "affix copyButton so that it doesn't get scrolled horizontally"
+   [\#2013](https://github.com/matrix-org/matrix-react-sdk/pull/2013)
+ * don't fire share dialog when clicking timestamp of event
+   [\#2017](https://github.com/matrix-org/matrix-react-sdk/pull/2017)
+ * when the user switches room, close room settings
+   [\#2019](https://github.com/matrix-org/matrix-react-sdk/pull/2019)
+
+Changes in [0.12.8-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.8-rc.2) (2018-06-22)
+===============================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.8-rc.1...v0.12.8-rc.2)
+
+ * slash got consumed in the consolidation
+   [\#1998](https://github.com/matrix-org/matrix-react-sdk/pull/1998)
+
+Changes in [0.12.8-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.8-rc.1) (2018-06-21)
+===============================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.7...v0.12.8-rc.1)
+
+ * Update from Weblate.
+   [\#1997](https://github.com/matrix-org/matrix-react-sdk/pull/1997)
+ * refactor, consolidate and improve SlashCommands
+   [\#1988](https://github.com/matrix-org/matrix-react-sdk/pull/1988)
+ * Take replies out of labs!
+   [\#1996](https://github.com/matrix-org/matrix-react-sdk/pull/1996)
+ * re-merge reset PR
+   [\#1987](https://github.com/matrix-org/matrix-react-sdk/pull/1987)
+ * once command has a space, strict match instead of fuzzy match
+   [\#1985](https://github.com/matrix-org/matrix-react-sdk/pull/1985)
+ * Fix matrix.to URL RegExp
+   [\#1986](https://github.com/matrix-org/matrix-react-sdk/pull/1986)
+ * Fix blank sticker picker
+   [\#1984](https://github.com/matrix-org/matrix-react-sdk/pull/1984)
+ * fix e2ee file/media stuff
+   [\#1972](https://github.com/matrix-org/matrix-react-sdk/pull/1972)
+ * right click for room tile context menu
+   [\#1978](https://github.com/matrix-org/matrix-react-sdk/pull/1978)
+ * only show m.room.message in FilePanel
+   [\#1983](https://github.com/matrix-org/matrix-react-sdk/pull/1983)
+ * improve command provider
+   [\#1981](https://github.com/matrix-org/matrix-react-sdk/pull/1981)
+ * affix copyButton so that it doesn't get scrolled horizontally
+   [\#1980](https://github.com/matrix-org/matrix-react-sdk/pull/1980)
+ * split continuation if there is a gap in conversation
+   [\#1979](https://github.com/matrix-org/matrix-react-sdk/pull/1979)
+ * fix a bunch of instances of react console spam
+   [\#1973](https://github.com/matrix-org/matrix-react-sdk/pull/1973)
+ * Track decryption success/failure rate with piwik
+   [\#1949](https://github.com/matrix-org/matrix-react-sdk/pull/1949)
+ * route matrix.to/#/+... links internally (not just group ids)
+   [\#1975](https://github.com/matrix-org/matrix-react-sdk/pull/1975)
+ * implement `hitting enter after Ctrl-K should switch to the first result`
+   [\#1976](https://github.com/matrix-org/matrix-react-sdk/pull/1976)
+ * Remove tag panel feature flag
+   [\#1970](https://github.com/matrix-org/matrix-react-sdk/pull/1970)
+ * QuestionDialog pass hasCancelButton to DialogButtons
+   [\#1968](https://github.com/matrix-org/matrix-react-sdk/pull/1968)
+ * check type before msgtype in the case of `m.sticker` with msgtype
+   [\#1965](https://github.com/matrix-org/matrix-react-sdk/pull/1965)
+ * apply roomlist searchFilter to aliases if it begins with a `#`
+   [\#1957](https://github.com/matrix-org/matrix-react-sdk/pull/1957)
+ * Share Dialog
+   [\#1948](https://github.com/matrix-org/matrix-react-sdk/pull/1948)
+ * make RoomTooltip generic and add ContextMenu&Tooltip to GroupInviteTile
+   [\#1950](https://github.com/matrix-org/matrix-react-sdk/pull/1950)
+ *  Fix widgets re-appearing after being deleted
+   [\#1958](https://github.com/matrix-org/matrix-react-sdk/pull/1958)
+ * Fix crash on unspecified thumbnail info, and handle gracefully
+   [\#1967](https://github.com/matrix-org/matrix-react-sdk/pull/1967)
+ * fix styling of clearButton when its not there
+   [\#1964](https://github.com/matrix-org/matrix-react-sdk/pull/1964)
+ *  Implement slightly magical CSS soln. to thumbnail sizing
+   [\#1912](https://github.com/matrix-org/matrix-react-sdk/pull/1912)
+ * Select audio output for WebRTC
+   [\#1932](https://github.com/matrix-org/matrix-react-sdk/pull/1932)
+ * move css rule to be more generic; remove overriden rule
+   [\#1962](https://github.com/matrix-org/matrix-react-sdk/pull/1962)
+ * improve tag panel accessibility and remove a no-op dispatch
+   [\#1960](https://github.com/matrix-org/matrix-react-sdk/pull/1960)
+ * Revert "Fix exception when opening dev tools"
+   [\#1963](https://github.com/matrix-org/matrix-react-sdk/pull/1963)
+ * fix message appears unencrypted while encrypting and not_sent
+   [\#1959](https://github.com/matrix-org/matrix-react-sdk/pull/1959)
+ * Fix exception when opening dev tools
+   [\#1961](https://github.com/matrix-org/matrix-react-sdk/pull/1961)
+ * show redacted stickers like other redacted messages
+   [\#1956](https://github.com/matrix-org/matrix-react-sdk/pull/1956)
+ * add mx_filterFlipColor to mx_MemberInfo_cancel img
+   [\#1951](https://github.com/matrix-org/matrix-react-sdk/pull/1951)
+ * don't set the displayname on registration as Synapse now does it
+   [\#1953](https://github.com/matrix-org/matrix-react-sdk/pull/1953)
+ * allow CreateRoom to scale properly horizontally
+   [\#1955](https://github.com/matrix-org/matrix-react-sdk/pull/1955)
+ * Keep context menus that extend downwards vertically on screen
+   [\#1952](https://github.com/matrix-org/matrix-react-sdk/pull/1952)
+ * re-run checkIfAlone if a member change occurred in the active room
+   [\#1947](https://github.com/matrix-org/matrix-react-sdk/pull/1947)
+ * Persist pinned message open-ness between room switches
+   [\#1935](https://github.com/matrix-org/matrix-react-sdk/pull/1935)
+ * Pinned message cosmetic improvements
+   [\#1933](https://github.com/matrix-org/matrix-react-sdk/pull/1933)
+ * Update sinon to 5.0.7
+   [\#1916](https://github.com/matrix-org/matrix-react-sdk/pull/1916)
+ * re-run checkIfAlone if a member change occurred in the active room
+   [\#1946](https://github.com/matrix-org/matrix-react-sdk/pull/1946)
+ * Replace "Login as guest" with "Try the app first" on login page
+   [\#1937](https://github.com/matrix-org/matrix-react-sdk/pull/1937)
+ * kill stream when using gUM for permission to device labels to turn off
+   camera
+   [\#1931](https://github.com/matrix-org/matrix-react-sdk/pull/1931)
+
+Changes in [0.12.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.7) (2018-06-12)
+=====================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.7-rc.1...v0.12.7)
+
+ * No changes since rc.1
+
+Changes in [0.12.7-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.7-rc.1) (2018-06-06)
+===============================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.6...v0.12.7-rc.1)
+
+ * Update from Weblate.
+   [\#1944](https://github.com/matrix-org/matrix-react-sdk/pull/1944)
+ * Import react as React in src/components/views/elements/DNDTagTile.js
+   [\#1943](https://github.com/matrix-org/matrix-react-sdk/pull/1943)
+ * Fix click on faded left/right/middle panel -> close settings
+   [\#1940](https://github.com/matrix-org/matrix-react-sdk/pull/1940)
+ * Add null-guard to support browsers that don't support performance
+   [\#1942](https://github.com/matrix-org/matrix-react-sdk/pull/1942)
+ * Support third party integration managers in AppPermission
+   [\#1455](https://github.com/matrix-org/matrix-react-sdk/pull/1455)
+ * Update pinned messages in real time
+   [\#1934](https://github.com/matrix-org/matrix-react-sdk/pull/1934)
+ * Expose at-room power level setting
+   [\#1938](https://github.com/matrix-org/matrix-react-sdk/pull/1938)
+
+Changes in [0.12.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.6) (2018-05-25)
+=====================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.6-rc.1...v0.12.6)
+
+ * No changes since v0.12.6-rc.1
+
+Changes in [0.12.6-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.6-rc.1) (2018-05-24)
+===============================================================================================================
+[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.5...v0.12.6-rc.1)
+
+ * Add a "reload widget" button.
+   [\#1920](https://github.com/matrix-org/matrix-react-sdk/pull/1920)
+ * Make devTools styling more consistent and easier to edit event data.
+   [\#1923](https://github.com/matrix-org/matrix-react-sdk/pull/1923)
+ * Update from Weblate.
+   [\#1930](https://github.com/matrix-org/matrix-react-sdk/pull/1930)
+ * Cookie bar update
+   [\#1929](https://github.com/matrix-org/matrix-react-sdk/pull/1929)
+ * Message for leaving server notices room
+   [\#1928](https://github.com/matrix-org/matrix-react-sdk/pull/1928)
+ * More thorough check of IM URL validity.
+   [\#1927](https://github.com/matrix-org/matrix-react-sdk/pull/1927)
+ * Add usage data link to cookie bar
+   [\#1926](https://github.com/matrix-org/matrix-react-sdk/pull/1926)
+ * Change wording and appearance of Deactivate Account dialog
+   [\#1925](https://github.com/matrix-org/matrix-react-sdk/pull/1925)
+ * fix membership list ordering when presence is disabled.
+   [\#1924](https://github.com/matrix-org/matrix-react-sdk/pull/1924)
+ * Implement erasure option upon deactivation
+   [\#1922](https://github.com/matrix-org/matrix-react-sdk/pull/1922)
+ * Add cookie warning to widget warning (AppPermission)
+   [\#1921](https://github.com/matrix-org/matrix-react-sdk/pull/1921)
+ * Terms and Conditions dialog
+   [\#1919](https://github.com/matrix-org/matrix-react-sdk/pull/1919)
+ * improve privileged section users in room settings
+   [\#1902](https://github.com/matrix-org/matrix-react-sdk/pull/1902)
+ * Space between sentences in 'leave room' warning
+   [\#1918](https://github.com/matrix-org/matrix-react-sdk/pull/1918)
+ * Specify valid address types to "Start a chat" dialog
+   [\#1908](https://github.com/matrix-org/matrix-react-sdk/pull/1908)
+ * Implement opt-in analytics with cookie bar
+   [\#1906](https://github.com/matrix-org/matrix-react-sdk/pull/1906)
+ * Fix vector-im/riot-web#6523 Emoji rendering destroys paragraphs
+   [\#1910](https://github.com/matrix-org/matrix-react-sdk/pull/1910)
+
 Changes in [0.12.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.5) (2018-05-17)
 =====================================================================================================
 [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.4...v0.12.5)
diff --git a/docs/slate-formats.md b/docs/slate-formats.md
new file mode 100644
index 0000000000..7bb2fc9c5f
--- /dev/null
+++ b/docs/slate-formats.md
@@ -0,0 +1,88 @@
+Guide to data types used by the Slate-based Rich Text Editor
+------------------------------------------------------------
+
+We always store the Slate editor state in its Value form.
+
+The schema for the Value is the same whether the editor is in MD or rich text mode, and is currently (rather arbitrarily)
+dictated by the schema expected by slate-md-serializer, simply because it was the only bit of the pipeline which
+has opinions on the schema. (slate-html-serializer lets you define how to serialize whatever schema you like).
+
+The BLOCK_TAGS and MARK_TAGS give the mapping from HTML tags to the schema's node types (for blocks, which describe
+block content like divs, and marks, which describe inline formatted sections like spans).
+
+We use 

as the parent tag for the message (XXX: although some tags are technically not allowed to be nested within p's) + +Various conversions are performed as content is moved between HTML, MD, and plaintext representations of HTML and MD. + +The primitives used are: + + * Markdown.js - models commonmark-formatted MD strings (as entered by the composer in MD mode) + * toHtml() - renders them to HTML suitable for sending on the wire + * isPlainText() - checks whether the parsed MD contains anything other than simple text. + * toPlainText() - renders MD to plain text in order to remove backslashes. Only works if the MD is already plaintext (otherwise it just emits HTML) + + * slate-html-serializer + * converts Values to HTML (serialising) using our schema rules + * converts HTML to Values (deserialising) using our schema rules + + * slate-md-serializer + * converts rich Values to MD strings (serialising) but using a non-commonmark generic MD dialect. + * This should use commonmark, but we use the serializer here for expedience rather than writing a commonmark one. + + * slate-plain-serializer + * converts Values to plain text strings (serialising them) by concatenating the strings together + * converts Values from plain text strings (deserialiasing them). + * Used to initialise the editor by deserializing "" into a Value. Apparently this is the idiomatic way to initialise a blank editor. + * Used (as a bodge) to turn a rich text editor into a MD editor, when deserialising the converted MD string of the editor into a value + + * PlainWithPillsSerializer + * A fork of slate-plain-serializer which is aware of Pills (hence the name) and Emoji. + * It can be configured to output Pills as: + * "plain": Pills are rendered via their 'completion' text - e.g. 'Matthew'; used for sending messages) + * "md": Pills are rendered as MD, e.g. [Matthew](https://matrix.to/#/@matthew:matrix.org) ) + * "id": Pills are rendered as IDs, e.g. '@matthew:matrix.org' (used for authoring / commands) + * Emoji nodes are converted to inline utf8 emoji. + +The actual conversion transitions are: + + * Quoting: + * The message being quoted is taken as HTML + * ...and deserialised into a Value + * ...and then serialised into MD via slate-md-serializer if the editor is in MD mode + + * Roundtripping between MD and rich text editor mode + * From MD to richtext (mdToRichEditorState): + * Serialise the MD-format Value to a MD string (converting pills to MD) with PlainWithPillsSerializer in 'md' mode + * Convert that MD string to HTML via Markdown.js + * Deserialise that Value to HTML via slate-html-serializer + * From richtext to MD (richToMdEditorState): + * Serialise the richtext-format Value to a MD string with slate-md-serializer (XXX: this should use commonmark) + * Deserialise that to a plain text value via slate-plain-serializer + + * Loading history in one format into an editor which is in the other format + * Uses the same functions as for roundtripping + + * Scanning the editor for a slash command + * If the editor is a single line node starting with /, then serialize it to a string with PlainWithPillsSerializer in 'id' mode + So that pills get converted to IDs suitable for commands being passed around + + * Sending messages + * In RT mode: + * If there is rich content, serialize the RT-format Value to HTML body via slate-html-serializer + * Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode + * In MD mode: + * Serialize the MD-format Value into an MD string with PlainWithPillsSerializer in 'md' mode + * Parse the string with Markdown.js + * If it contains no formatting: + * Send as plaintext (as taken from Markdown.toPlainText()) + * Otherwise + * Send as HTML (as taken from Markdown.toHtml()) + * Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode + + * Pasting HTML + * Deserialize HTML to a RT Value via slate-html-serializer + * In RT mode, insert it straight into the editor as a fragment + * In MD mode, serialise it to an MD string via slate-md-serializer and then insert the string into the editor as a fragment. + +The various scenarios and transitions could be drawn into a pretty diagram if one felt the urge, but hopefully the above +gives sufficient detail on how it's all meant to work. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f183f1635d..3dedf8dcd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,18 @@ { "name": "matrix-react-sdk", - "version": "0.12.2", + "version": "0.12.9", "lockfileVersion": 1, "requires": true, "dependencies": { + "@sinonjs/formatio": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-2.0.0.tgz", + "integrity": "sha512-ls6CAMA6/5gG+O/IdsBcblvnd8qcO/l1TYoNeAzp3wcISOxlPXQEus0mLcdwazEkWjaBdaJ3TaxmNgCLWwvWzg==", + "dev": true, + "requires": { + "samsam": "1.3.0" + } + }, "accepts": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz", @@ -44,14 +53,14 @@ "dev": true }, "ajv": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.2.3.tgz", - "integrity": "sha1-wG9Zh3jETGsWGrr+NGa4GtGBTtI=", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", "requires": { "co": "4.6.0", - "fast-deep-equal": "1.0.0", - "json-schema-traverse": "0.3.1", - "json-stable-stringify": "1.0.1" + "fast-deep-equal": "1.1.0", + "fast-json-stable-stringify": "2.0.0", + "json-schema-traverse": "0.3.1" } }, "ajv-keywords": { @@ -238,9 +247,9 @@ "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" }, "aws4": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz", - "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4=" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz", + "integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==" }, "babel-cli": { "version": "6.26.0", @@ -1200,11 +1209,6 @@ "type-is": "1.6.15" } }, - "boom": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/boom/-/boom-4.3.1.tgz", - "integrity": "sha1-T4owBctKfjiJ90kDD9JbluAdLjE=" - }, "brace-expansion": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", @@ -1440,9 +1444,9 @@ } }, "combined-stream": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz", - "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", "requires": { "delayed-stream": "1.0.0" } @@ -1580,21 +1584,6 @@ "object-assign": "4.1.1" } }, - "cryptiles": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-3.1.2.tgz", - "integrity": "sha1-qJ+7Ig9c4l7FboxKqKT9e1sNKf4=", - "requires": { - "boom": "5.2.0" - }, - "dependencies": { - "boom": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", - "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==" - } - } - }, "crypto-browserify": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.3.0.tgz", @@ -1713,6 +1702,17 @@ "integrity": "sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw=", "dev": true }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "direction": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/direction/-/direction-0.1.5.tgz", + "integrity": "sha1-zl15f5fib4vnvv9T99xA4cGp7Ew=" + }, "doctrine": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.0.0.tgz", @@ -1779,37 +1779,6 @@ "domelementtype": "1.3.0" } }, - "draft-js": { - "version": "0.11.0-alpha", - "resolved": "https://registry.npmjs.org/draft-js/-/draft-js-0.11.0-alpha.tgz", - "integrity": "sha1-MtshCPkn6bhEbaH3nkR1wrf4aK4=", - "requires": { - "fbjs": "0.8.16", - "immutable": "3.7.6", - "object-assign": "4.1.1" - } - }, - "draft-js-export-html": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/draft-js-export-html/-/draft-js-export-html-0.6.0.tgz", - "integrity": "sha1-zIDwVExD0Kf+28U8DLCRToCQ92k=", - "requires": { - "draft-js-utils": "1.2.0" - } - }, - "draft-js-export-markdown": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/draft-js-export-markdown/-/draft-js-export-markdown-0.3.0.tgz", - "integrity": "sha1-hjkOA86vHTR/xhaGerf1Net2v0I=", - "requires": { - "draft-js-utils": "1.2.0" - } - }, - "draft-js-utils": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/draft-js-utils/-/draft-js-utils-1.2.0.tgz", - "integrity": "sha1-9csj6xZzJf/tPXmIL9wxdyHS/RI=" - }, "ecc-jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz", @@ -2242,6 +2211,11 @@ "object-assign": "4.1.1" } }, + "esrever": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/esrever/-/esrever-0.2.0.tgz", + "integrity": "sha1-lunSj08bGnZ4TNXUkOquAQ50B7g=" + }, "estraverse": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", @@ -2393,9 +2367,14 @@ "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" }, "fast-deep-equal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz", - "integrity": "sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" }, "fast-levenshtein": { "version": "2.0.6", @@ -2616,24 +2595,15 @@ "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" }, "form-data": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.1.tgz", - "integrity": "sha1-b7lPvXGIUwbXPRXMSX/kzE7NRL8=", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", "requires": { "asynckit": "0.4.0", - "combined-stream": "1.0.5", + "combined-stream": "1.0.6", "mime-types": "2.1.17" } }, - "formatio": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.1.1.tgz", - "integrity": "sha1-XtPM1jZVEJc4NGXZlhmRAOhhYek=", - "dev": true, - "requires": { - "samsam": "1.1.2" - } - }, "fs-access": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fs-access/-/fs-access-1.0.1.tgz", @@ -3583,6 +3553,19 @@ "is-property": "1.0.2" } }, + "get-document": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-document/-/get-document-1.0.0.tgz", + "integrity": "sha1-SCG85m8cJMsDMWAr5strEsTwHEs=" + }, + "get-window": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/get-window/-/get-window-1.1.2.tgz", + "integrity": "sha512-yjWpFcy9fjhLQHW1dPtg9ga4pmizLY8y4ZSHdGrAQ1NU277MRhnGnnLPxe19X8W5lWVsCZz++5xEuNozWMVmTw==", + "requires": { + "get-document": "1.0.0" + } + }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -3679,7 +3662,7 @@ "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", "requires": { - "ajv": "5.2.3", + "ajv": "5.5.2", "har-schema": "2.0.0" } }, @@ -3730,16 +3713,6 @@ "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", "dev": true }, - "hawk": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", - "integrity": "sha512-miowhl2+U7Qle4vdLqDdPt9m09K6yZhkLDTWGoUiUzrQCn+mHHSmfJgAyGaLRZbPmTqfFFjRV1QWCW0VWUJBbQ==", - "requires": { - "boom": "4.3.1", - "cryptiles": "3.1.2", - "sntp": "2.0.2" - } - }, "he": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", @@ -3808,7 +3781,7 @@ "requires": { "assert-plus": "1.0.0", "jsprim": "1.4.1", - "sshpk": "1.13.1" + "sshpk": "1.14.2" } }, "https-browserify": { @@ -3947,6 +3920,11 @@ "integrity": "sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=", "dev": true }, + "is-empty": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-empty/-/is-empty-1.2.0.tgz", + "integrity": "sha1-3pu1snhzigWgsJpX4ftNSjQan2s=" + }, "is-equal": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/is-equal/-/is-equal-1.5.5.tgz", @@ -4020,6 +3998,16 @@ "is-extglob": "1.0.0" } }, + "is-hotkey": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.1.2.tgz", + "integrity": "sha512-H05t1Tc+uCWLOjHtF1bZH3b/i7+RkjlhwFWDsroTwC0acCjXOVyjJ63yTnRGdNhV5bnKIcYnijgLAJtM0/Xqsw==" + }, + "is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" + }, "is-my-json-valid": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.16.1.tgz", @@ -4071,6 +4059,21 @@ "path-is-inside": "1.0.2" } }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "requires": { + "isobject": "3.0.1" + }, + "dependencies": { + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=" + } + } + }, "is-posix-bracket": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz", @@ -4129,6 +4132,11 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, + "is-window": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-window/-/is-window-1.0.2.tgz", + "integrity": "sha1-LIlspT25feRdPDMTOmXYyfVjSA0=" + }, "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4155,6 +4163,11 @@ "isarray": "1.0.0" } }, + "isomorphic-base64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/isomorphic-base64/-/isomorphic-base64-1.0.2.tgz", + "integrity": "sha1-9Caq6CVpuopOxcpzrSGkSrHueAM=" + }, "isomorphic-fetch": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", @@ -4221,6 +4234,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "dev": true, "requires": { "jsonify": "0.0.0" } @@ -4245,7 +4259,8 @@ "jsonify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", - "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=" + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true }, "jsonpointer": { "version": "4.0.1", @@ -4273,6 +4288,12 @@ "array-includes": "3.0.3" } }, + "just-extend": { + "version": "1.1.27", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-1.1.27.tgz", + "integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==", + "dev": true + }, "karma": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/karma/-/karma-1.7.1.tgz", @@ -4428,6 +4449,11 @@ } } }, + "keycode": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz", + "integrity": "sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ=" + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -4454,13 +4480,45 @@ } }, "linkifyjs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-2.1.5.tgz", - "integrity": "sha512-8FqxPXQDLjI2nNHlM7eGewxE6DHvMbtiW0AiXzm0s4RkTwVZYRDTeVXkiRxLHTd4CuRBQY/JPtvtqJWdS7gHyA==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-2.1.6.tgz", + "integrity": "sha512-nA94bEM9rmt7Iu4OEIYSKpW+Dy6fhlBTjk2Bg9bFuxHQYcy+lWq2EleHb0rp/ev8oBO82vLHZctM5YlSR5DTzw==", "requires": { - "jquery": "3.2.1", - "react": "15.6.2", - "react-dom": "15.6.2" + "jquery": "3.3.1", + "react": "16.4.1", + "react-dom": "16.4.1" + }, + "dependencies": { + "jquery": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz", + "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg==", + "optional": true + }, + "react": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/react/-/react-16.4.1.tgz", + "integrity": "sha512-3GEs0giKp6E0Oh/Y9ZC60CmYgUPnp7voH9fbjWsvXtYFb4EWtgQub0ADSq0sJR0BbHc4FThLLtzlcFaFXIorwg==", + "optional": true, + "requires": { + "fbjs": "0.8.16", + "loose-envify": "1.3.1", + "object-assign": "4.1.1", + "prop-types": "15.6.0" + } + }, + "react-dom": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.4.1.tgz", + "integrity": "sha512-1Gin+wghF/7gl4Cqcvr1DxFX2Osz7ugxSwl6gBqCMpdrxHjIFUS7GYxrFftZ9Ln44FHw0JxCFD9YtZsrbR5/4A==", + "optional": true, + "requires": { + "fbjs": "0.8.16", + "loose-envify": "1.3.1", + "object-assign": "4.1.1", + "prop-types": "15.6.0" + } + } } }, "loader-utils": { @@ -4491,6 +4549,12 @@ "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, "lodash.pickby": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.pickby/-/lodash.pickby-4.6.0.tgz", @@ -4534,10 +4598,9 @@ } }, "lolex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-1.3.2.tgz", - "integrity": "sha1-fD2mL/yzDw9agKJWbKJORdigHzE=", - "dev": true + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.3.2.tgz", + "integrity": "sha512-A5pN2tkFj7H0dGIAM6MFvHKMJcPnjZsOMvR7ujCjfgW5TbV6H9vb1PgxLtHvjqNZTHsUolz+6/WEO0N1xNx2ng==" }, "longest": { "version": "1.0.1", @@ -4560,16 +4623,16 @@ "dev": true }, "matrix-js-sdk": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-0.10.1.tgz", - "integrity": "sha512-BLo+Okn2o///TyWBKtjFXvhlD32vGfr10eTE51hHx/jwaXO82VyGMzMi+IDPS4SDYUbvXI7PpamECeh9TXnV2w==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-0.10.6.tgz", + "integrity": "sha512-8aC6wpHBCBgnJ/v0FjfBkjNo2BR3LlU5hIM2ql5OqxTbY64miJ5WKr8hD4meD86C/TSHtzJtlLrlThO0vmHuwg==", "requires": { "another-json": "0.2.0", "babel-runtime": "6.26.0", "bluebird": "3.5.1", "browser-request": "0.3.3", "content-type": "1.0.4", - "request": "2.83.0" + "request": "2.87.0" } }, "matrix-mock-request": { @@ -4762,8 +4825,7 @@ "ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" }, "mute-stream": { "version": "0.0.5", @@ -4790,6 +4852,27 @@ "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk=", "dev": true }, + "nise": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.3.3.tgz", + "integrity": "sha512-v1J/FLUB9PfGqZLGDBhQqODkbLotP0WtLo9R4EJY2PPu5f5Xg4o0rA8FDlmrjFSv9vBBKcfnOSpfYYuu5RTHqg==", + "dev": true, + "requires": { + "@sinonjs/formatio": "2.0.0", + "just-extend": "1.1.27", + "lolex": "2.6.0", + "path-to-regexp": "1.7.0", + "text-encoding": "0.6.4" + }, + "dependencies": { + "lolex": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.6.0.tgz", + "integrity": "sha512-e1UtIo1pbrIqEXib/yMjHciyqkng5lc0rrIbytgjmRgDR9+2ceNIAcwOWSgylRjoEP9VdVguCSRwnNmlbnOUwA==", + "dev": true + } + } + }, "node-fetch": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", @@ -5093,6 +5176,23 @@ "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", "dev": true }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, "pbkdf2-compat": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz", @@ -5215,6 +5315,19 @@ "integrity": "sha1-ZZ3p8s+NzCehSBJ28gU3cnI4LnM=", "dev": true }, + "qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha1-ys6GOG9ZoNuAUPqQ2baw6IoeNk8=" + }, + "qrcode-react": { + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/qrcode-react/-/qrcode-react-0.1.16.tgz", + "integrity": "sha512-FK+QCfFqCQMSxUE1byzglERJQkwKqXYvYMCS+/Ad2zACJOfoHkHHtRqsQQPji7lfb1y1qCXLvL+3eP1hAfg8Ng==", + "requires": { + "qr.js": "0.0.0" + } + }, "qs": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.1.tgz", @@ -5360,6 +5473,11 @@ "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#b302279810d05319ac5ff1bd34910bff32325c7b" } }, + "react-immutable-proptypes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz", + "integrity": "sha1-Aj1vObsVyXwHHp5g0A0TbqxfoLQ=" + }, "react-motion": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/react-motion/-/react-motion-0.5.2.tgz", @@ -5377,6 +5495,14 @@ } } }, + "react-portal": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-portal/-/react-portal-3.2.0.tgz", + "integrity": "sha512-avb1FreAZAVCvNNyS2dCpxZiPYPJnAasHYPxdVBTROgNFeI+KSb+OoMHNsC1GbDawESCriPwCX+qKua6WSPIFw==", + "requires": { + "prop-types": "15.6.0" + } + }, "react-redux": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.7.tgz", @@ -5557,19 +5683,18 @@ } }, "request": { - "version": "2.83.0", - "resolved": "https://registry.npmjs.org/request/-/request-2.83.0.tgz", - "integrity": "sha512-lR3gD69osqm6EYLk9wB/G1W/laGWjzH90t1vEa2xuxHD5KUrSzp9pUSfTm+YC5Nxt2T8nMPEvKlhbQayU7bgFw==", + "version": "2.87.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", + "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", "requires": { "aws-sign2": "0.7.0", - "aws4": "1.6.0", + "aws4": "1.7.0", "caseless": "0.12.0", - "combined-stream": "1.0.5", + "combined-stream": "1.0.6", "extend": "3.0.1", "forever-agent": "0.6.1", - "form-data": "2.3.1", + "form-data": "2.3.2", "har-validator": "5.0.3", - "hawk": "6.0.2", "http-signature": "1.2.0", "is-typedarray": "1.0.0", "isstream": "0.1.2", @@ -5579,10 +5704,9 @@ "performance-now": "2.1.0", "qs": "6.5.1", "safe-buffer": "5.1.1", - "stringstream": "0.0.5", - "tough-cookie": "2.3.3", + "tough-cookie": "2.3.4", "tunnel-agent": "0.6.0", - "uuid": "3.1.0" + "uuid": "3.3.0" } }, "require-json": { @@ -5612,6 +5736,11 @@ "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", "integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc=" }, + "resize-observer-polyfill": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.0.tgz", + "integrity": "sha512-M2AelyJDVR/oLnToJLtuDJRBBWUGUvvGigj1411hXhAdyFWqMaqHp7TixW3FpiLuVaikIcR1QL+zqoJoZlOgpg==" + }, "resolve": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.4.0.tgz", @@ -5697,10 +5826,15 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "samsam": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.1.2.tgz", - "integrity": "sha1-vsEf3IOp/aBjQBIQ5AF2wwJNFWc=", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", + "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==", "dev": true }, "sanitize-html": { @@ -5713,6 +5847,11 @@ "xtend": "4.0.1" } }, + "selection-is-backward": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/selection-is-backward/-/selection-is-backward-1.0.0.tgz", + "integrity": "sha1-l6VGMxiKURq6ZBn8XB+pG0Z+a+E=" + }, "semver": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz", @@ -5770,15 +5909,41 @@ } }, "sinon": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-1.17.7.tgz", - "integrity": "sha1-RUKk9JugxFwF6y6d2dID4rjv4L8=", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-5.0.7.tgz", + "integrity": "sha512-GvNLrwpvLZ8jIMZBUhHGUZDq5wlUdceJWyHvZDmqBxnjazpxY1L0FNbGBX6VpcOEoQ8Q4XMWFzm2myJMvx+VjA==", "dev": true, "requires": { - "formatio": "1.1.1", - "lolex": "1.3.2", - "samsam": "1.1.2", - "util": "0.10.3" + "@sinonjs/formatio": "2.0.0", + "diff": "3.5.0", + "lodash.get": "4.4.2", + "lolex": "2.6.0", + "nise": "1.3.3", + "supports-color": "5.4.0", + "type-detect": "4.0.8" + }, + "dependencies": { + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "lolex": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.6.0.tgz", + "integrity": "sha512-e1UtIo1pbrIqEXib/yMjHciyqkng5lc0rrIbytgjmRgDR9+2ceNIAcwOWSgylRjoEP9VdVguCSRwnNmlbnOUwA==", + "dev": true + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "3.0.0" + } + } } }, "slash": { @@ -5787,17 +5952,133 @@ "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", "dev": true }, + "slate": { + "version": "0.34.7", + "resolved": "https://registry.npmjs.org/slate/-/slate-0.34.7.tgz", + "integrity": "sha1-WQjh0PwJKiISSIvsplZx8B4OuAo=", + "requires": { + "debug": "3.1.0", + "direction": "0.1.5", + "esrever": "0.2.0", + "is-empty": "1.2.0", + "is-plain-object": "2.0.4", + "lodash": "4.17.4", + "slate-dev-logger": "0.1.39", + "slate-schema-violations": "0.1.20", + "type-of": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "slate-base64-serializer": { + "version": "0.2.41", + "resolved": "https://registry.npmjs.org/slate-base64-serializer/-/slate-base64-serializer-0.2.41.tgz", + "integrity": "sha1-z+yhA7X9rd2WeOACWADfw174QJg=", + "requires": { + "isomorphic-base64": "1.0.2" + } + }, + "slate-dev-environment": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/slate-dev-environment/-/slate-dev-environment-0.1.2.tgz", + "integrity": "sha1-dDqL1/Qn3CckJbBDminoPKVSFog=", + "requires": { + "is-in-browser": "1.1.3" + } + }, + "slate-dev-logger": { + "version": "0.1.39", + "resolved": "https://registry.npmjs.org/slate-dev-logger/-/slate-dev-logger-0.1.39.tgz", + "integrity": "sha1-dEppuFA0JEcT5t5RSDr1cTw0WvQ=" + }, + "slate-hotkeys": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/slate-hotkeys/-/slate-hotkeys-0.1.2.tgz", + "integrity": "sha1-LjWgikLqqhE7ZNQ41TfnelgsjUo=", + "requires": { + "is-hotkey": "0.1.2", + "slate-dev-environment": "0.1.2" + } + }, + "slate-html-serializer": { + "version": "0.6.13", + "resolved": "https://registry.npmjs.org/slate-html-serializer/-/slate-html-serializer-0.6.13.tgz", + "integrity": "sha1-7kzECd/w+Bk/OddemXKJTrmsydo=", + "requires": { + "slate-dev-logger": "0.1.39", + "type-of": "2.0.1" + } + }, + "slate-md-serializer": { + "version": "github:matrix-org/slate-md-serializer#f7c4ad394f5af34d4c623de7909ce95ab78072d3" + }, + "slate-plain-serializer": { + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/slate-plain-serializer/-/slate-plain-serializer-0.5.22.tgz", + "integrity": "sha1-kcgbdDi02M03Rqu8oA5XTlFL6bw=", + "requires": { + "slate-dev-logger": "0.1.39" + } + }, + "slate-prop-types": { + "version": "0.4.39", + "resolved": "https://registry.npmjs.org/slate-prop-types/-/slate-prop-types-0.4.39.tgz", + "integrity": "sha1-+taDqyVzIa1LP4NvrkDopfWc0Jo=", + "requires": { + "slate-dev-logger": "0.1.39" + } + }, + "slate-react": { + "version": "0.12.11", + "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.12.11.tgz", + "integrity": "sha1-bYPmBGNHBHV2kKV9vWqrKCqWStM=", + "requires": { + "debug": "3.1.0", + "get-window": "1.1.2", + "is-window": "1.0.2", + "keycode": "2.2.0", + "lodash": "4.17.4", + "prop-types": "15.6.0", + "react-immutable-proptypes": "2.1.0", + "react-portal": "3.2.0", + "selection-is-backward": "1.0.0", + "slate-base64-serializer": "0.2.41", + "slate-dev-environment": "0.1.2", + "slate-dev-logger": "0.1.39", + "slate-hotkeys": "0.1.2", + "slate-plain-serializer": "0.5.22", + "slate-prop-types": "0.4.39" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "slate-schema-violations": { + "version": "0.1.20", + "resolved": "https://registry.npmjs.org/slate-schema-violations/-/slate-schema-violations-0.1.20.tgz", + "integrity": "sha1-v2O0+ylbkPQhiTlWStr80cLyF1k=" + }, "slice-ansi": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", "dev": true }, - "sntp": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/sntp/-/sntp-2.0.2.tgz", - "integrity": "sha1-UGQRDwr4X3z9t9a2ekACjOUrSys=" - }, "socket.io": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-1.7.3.tgz", @@ -5995,9 +6276,9 @@ "integrity": "sha1-Nr54Mgr+WAH2zqPueLblqrlA6gw=" }, "sshpk": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.1.tgz", - "integrity": "sha1-US322mKHFEMW3EwY/hzx2UBzm+M=", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", "requires": { "asn1": "0.2.3", "assert-plus": "1.0.0", @@ -6006,6 +6287,7 @@ "ecc-jsbn": "0.1.1", "getpass": "0.1.7", "jsbn": "0.1.1", + "safer-buffer": "2.1.2", "tweetnacl": "0.14.5" } }, @@ -6062,11 +6344,6 @@ "safe-buffer": "5.1.1" } }, - "stringstream": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz", - "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg=" - }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -6167,6 +6444,12 @@ "integrity": "sha1-KcNXB8K3DlDQdIK10gLo7URtr9Q=", "dev": true }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, "text-encoding-utf-8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.1.tgz", @@ -6233,9 +6516,9 @@ "dev": true }, "tough-cookie": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.3.tgz", - "integrity": "sha1-C2GKVWW23qkL80JdBNVe3EdadWE=", + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", "requires": { "punycode": "1.4.1" } @@ -6281,6 +6564,12 @@ "prelude-ls": "1.1.2" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-is": { "version": "1.6.15", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz", @@ -6291,6 +6580,11 @@ "mime-types": "2.1.17" } }, + "type-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-of/-/type-of-2.0.1.tgz", + "integrity": "sha1-5yoXQYllaOn2KDeNgW1pEvfyOXI=" + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -6401,9 +6695,9 @@ "dev": true }, "uuid": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.1.0.tgz", - "integrity": "sha512-DIWtzUkw04M4k3bf1IcpS2tngXEL26YUD2M0tMDUpnUrz2hgzUBlD55a4FjdLGPvfHxS6uluGWvaVEqgBcVa+g==" + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.0.tgz", + "integrity": "sha512-ijO9N2xY/YaOqQ5yz5c4sy2ZjWmA6AR6zASb/gdpeKZ8+948CxwfMW9RrKVk5may6ev8c0/Xguu32e2Llelpqw==" }, "v8flags": { "version": "2.1.1", diff --git a/package.json b/package.json index 6c34979c43..f79e0abd82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.12.5", + "version": "0.12.9", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -59,9 +59,6 @@ "classnames": "^2.1.2", "commonmark": "^0.28.1", "counterpart": "^0.18.0", - "draft-js": "^0.11.0-alpha", - "draft-js-export-html": "^0.6.0", - "draft-js-export-markdown": "^0.3.0", "emojione": "2.2.7", "file-saver": "^1.3.3", "filesize": "3.5.6", @@ -73,19 +70,25 @@ "glob": "^5.0.14", "highlight.js": "^9.0.0", "isomorphic-fetch": "^2.2.1", - "linkifyjs": "^2.1.3", + "linkifyjs": "^2.1.6", "lodash": "^4.13.1", "lolex": "2.3.2", - "matrix-js-sdk": "0.10.2", + "matrix-js-sdk": "0.10.6", "optimist": "^0.6.1", "pako": "^1.0.5", "prop-types": "^15.5.8", + "qrcode-react": "^0.1.16", "querystring": "^0.2.0", "react": "^15.6.0", "react-addons-css-transition-group": "15.3.2", "react-beautiful-dnd": "^4.0.1", "react-dom": "^15.6.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", + "resize-observer-polyfill": "^1.5.0", + "slate": "0.34.7", + "slate-react": "^0.12.4", + "slate-html-serializer": "^0.6.1", + "slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3", "sanitize-html": "^1.14.1", "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", @@ -134,7 +137,7 @@ "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", - "sinon": "^1.17.3", + "sinon": "^5.0.7", "source-map-loader": "^0.2.3", "walk": "^2.3.9", "webpack": "^1.12.14" diff --git a/res/css/_common.scss b/res/css/_common.scss index 7aa62698c3..ce3e9afdd7 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -289,6 +289,10 @@ textarea { vertical-align: middle; } +.mx_emojione_selected { + background-color: $accent-color; +} + ::-moz-selection { background-color: $accent-color; color: $selection-fg-color; diff --git a/res/css/_components.scss b/res/css/_components.scss index 8d541730ce..6a0349bdd4 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -42,6 +42,7 @@ @import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SetMxIdDialog.scss"; @import "./views/dialogs/_SetPasswordDialog.scss"; +@import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_UnknownDeviceDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; diff --git a/res/css/structures/_ContextualMenu.scss b/res/css/structures/_ContextualMenu.scss index a0191b92cf..7474c3d107 100644 --- a/res/css/structures/_ContextualMenu.scss +++ b/res/css/structures/_ContextualMenu.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_ContextualMenu_wrapper { position: fixed; - z-index: 2000; + z-index: 5000; } .mx_ContextualMenu_background { @@ -26,7 +26,7 @@ limitations under the License. width: 100%; height: 100%; opacity: 1.0; - z-index: 2000; + z-index: 5000; } .mx_ContextualMenu { @@ -37,7 +37,7 @@ limitations under the License. position: absolute; padding: 6px; font-size: 14px; - z-index: 2001; + z-index: 5001; } .mx_ContextualMenu.mx_ContextualMenu_right { diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 291aa3689d..c0298a048a 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -56,6 +57,10 @@ limitations under the License. } +.mx_LeftPanel .mx_AppTile_mini { + height: 132px; +} + .mx_LeftPanel .mx_RoomList_scrollbar { order: 1; diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index ca7431eac2..2a9cc9f6c7 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -113,6 +113,8 @@ limitations under the License. } .mx_RoomStatusBar_connectionLostBar { + display: flex; + margin-top: 19px; min-height: 58px; } @@ -132,6 +134,7 @@ limitations under the License. color: $primary-fg-color; font-size: 13px; opacity: 0.5; + padding-bottom: 20px; } .mx_RoomStatusBar_resend_link { diff --git a/res/css/structures/_RoomSubList.scss b/res/css/structures/_RoomSubList.scss index 3568dacee3..a551e5f1af 100644 --- a/res/css/structures/_RoomSubList.scss +++ b/res/css/structures/_RoomSubList.scss @@ -91,6 +91,10 @@ limitations under the License. background-color: $accent-color; } +.mx_RoomSubList_label .mx_RoomSubList_badge:hover { + filter: brightness($focus-brightness); +} + /* .collapsed .mx_RoomSubList_badge { display: none; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 4223f61a4c..e83f802012 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -173,10 +173,7 @@ hr.mx_RoomView_myReadMarker { z-index: 1000; overflow: hidden; - -webkit-transition: all .2s ease-out; - -moz-transition: all .2s ease-out; - -ms-transition: all .2s ease-out; - -o-transition: all .2s ease-out; + transition: all .2s ease-out; } .mx_RoomView_statusArea_expanded { diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_TagPanel.scss index 1bc14bdccc..ea8a1975b5 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_TagPanel.scss @@ -15,8 +15,8 @@ limitations under the License. */ .mx_TagPanel { - flex: 0 0 70px; - background-color: $tagpanel-bg-color; + flex: 0 0 60px; + background-color: $tertiary-accent-color; cursor: pointer; display: flex; @@ -25,7 +25,11 @@ limitations under the License. justify-content: space-between; } -.mx_TagPanel .mx_TagPanel_clearButton { +.mx_TagPanel_items_selected { + cursor: pointer; +} + +.mx_TagPanel .mx_TagPanel_clearButton_container { /* Constant height within flex mx_TagPanel */ height: 70px; width: 60px; diff --git a/res/css/views/dialogs/_CreateRoomDialog.scss b/res/css/views/dialogs/_CreateRoomDialog.scss index 888f147d21..05d5bfcebf 100644 --- a/res/css/views/dialogs/_CreateRoomDialog.scss +++ b/res/css/views/dialogs/_CreateRoomDialog.scss @@ -23,6 +23,10 @@ limitations under the License. padding-bottom: 12px; } +.mx_CreateRoomDialog_input_container { + padding-right: 20px; +} + .mx_CreateRoomDialog_input { font-size: 15px; border-radius: 3px; @@ -30,4 +34,5 @@ limitations under the License. padding: 9px; color: $primary-fg-color; background-color: $primary-bg-color; + width: 100%; } diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 8918373ecf..a4a868bd11 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -14,8 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_DevTools_content { + margin: 10px 0; +} + .mx_DevTools_RoomStateExplorer_button, .mx_DevTools_RoomStateExplorer_query { margin-bottom: 10px; + width: 100%; } .mx_DevTools_label_left { @@ -38,7 +43,6 @@ limitations under the License. .mx_DevTools_inputLabelCell { - padding-bottom: 21px; display: table-cell; font-weight: bold; padding-right: 24px; @@ -46,7 +50,6 @@ limitations under the License. .mx_DevTools_inputCell { display: table-cell; - padding-bottom: 21px; width: 240px; } @@ -62,6 +65,14 @@ limitations under the License. font-size: 16px; } +.mx_DevTools_textarea { + font-size: 12px; + max-width: 624px; + min-height: 250px; + padding: 10px; + width: 100%; +} + .mx_DevTools_tgl { display: none; diff --git a/res/css/views/dialogs/_ShareDialog.scss b/res/css/views/dialogs/_ShareDialog.scss new file mode 100644 index 0000000000..116bef8dfd --- /dev/null +++ b/res/css/views/dialogs/_ShareDialog.scss @@ -0,0 +1,89 @@ +/* +Copyright 2018 New Vector Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ShareDialog { + // this is to center the content + padding-right: 58px; +} + +.mx_ShareDialog hr { + margin-top: 25px; + margin-bottom: 25px; + border-color: $light-fg-color; +} + +.mx_ShareDialog_content { + margin: 10px 0; +} + +.mx_ShareDialog_matrixto { + display: flex; + justify-content: space-between; + border-radius: 5px; + border: solid 1px $light-fg-color; + margin-bottom: 10px; + margin-top: 30px; + padding: 10px; +} + +.mx_ShareDialog_matrixto a { + text-decoration: none; +} + +.mx_ShareDialog_matrixto_link { + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +.mx_ShareDialog_matrixto_copy { + flex-shrink: 0; + cursor: pointer; + margin-left: 20px; + display: inherit; +} +.mx_ShareDialog_matrixto_copy > div { + background-image: url($copy-button-url); + margin-left: 5px; + width: 20px; + height: 20px; +} + +.mx_ShareDialog_split { + display: flex; + flex-wrap: wrap; +} + +.mx_ShareDialog_qrcode_container { + float: left; + background-color: #ffffff; + padding: 5px; // makes qr code more readable in dark theme + border-radius: 5px; + height: 256px; + width: 256px; + margin-right: 64px; +} + +.mx_ShareDialog_social_container { + display: inline-block; + width: 299px; +} + +.mx_ShareDialog_social_icon { + display: inline-grid; + margin-right: 10px; + margin-bottom: 10px; +} diff --git a/res/css/views/elements/_RichText.scss b/res/css/views/elements/_RichText.scss index 474a123455..cea4b7897d 100644 --- a/res/css/views/elements/_RichText.scss +++ b/res/css/views/elements/_RichText.scss @@ -4,6 +4,7 @@ .mx_UserPill, .mx_RoomPill, +.mx_GroupPill, .mx_AtRoomPill { border-radius: 16px; display: inline-block; @@ -13,7 +14,8 @@ } .mx_EventTile_body .mx_UserPill, -.mx_EventTile_body .mx_RoomPill { +.mx_EventTile_body .mx_RoomPill, +.mx_EventTile_body .mx_GroupPill { cursor: pointer; } @@ -25,6 +27,10 @@ padding-right: 5px; } +.mx_UserPill_selected { + background-color: $accent-color ! important; +} + .mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me, .mx_EventTile_content .mx_AtRoomPill, .mx_MessageComposer_input .mx_AtRoomPill { @@ -35,14 +41,25 @@ /* More specific to override `.markdown-body a` color */ .mx_EventTile_content .markdown-body a.mx_RoomPill, -.mx_RoomPill { +.mx_EventTile_content .markdown-body a.mx_GroupPill, +.mx_RoomPill, +.mx_GroupPill { color: $accent-fg-color; background-color: $rte-room-pill-color; padding-right: 5px; } +/* More specific to override `.markdown-body a` color */ +.mx_EventTile_content .markdown-body a.mx_GroupPill, +.mx_GroupPill { + color: $accent-fg-color; + background-color: $rte-group-pill-color; + padding-right: 5px; +} + .mx_UserPill .mx_BaseAvatar, .mx_RoomPill .mx_BaseAvatar, +.mx_GroupPill .mx_BaseAvatar, .mx_AtRoomPill .mx_BaseAvatar { position: relative; left: -3px; diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 1c809f0743..4c763c5991 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -20,5 +20,29 @@ limitations under the License. } .mx_MImageBody_thumbnail { - max-width: 100%; -} \ No newline at end of file + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; +} + +.mx_MImageBody_thumbnail_container { + // Prevent the padding-bottom (added inline in MImageBody.js) from + // affecting elements below the container. + overflow: hidden; + + // Make sure the _thumbnail is positioned relative to the _container + position: relative; +} + +.mx_MImageBody_thumbnail_spinner { + position: absolute; + left: 50%; + top: 50%; +} + +// Inner img and TintableSvg should be centered around 0, 0 +.mx_MImageBody_thumbnail_spinner > * { + transform: translate(-50%, -50%); +} diff --git a/res/css/views/messages/_MStickerBody.scss b/res/css/views/messages/_MStickerBody.scss index 3e6bbe5aa4..e4977bcc34 100644 --- a/res/css/views/messages/_MStickerBody.scss +++ b/res/css/views/messages/_MStickerBody.scss @@ -14,33 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MStickerBody { - display: block; - margin-right: 34px; - min-height: 110px; - padding: 20px 0; +.mx_MStickerBody_wrapper { + padding: 20px 0px; } -.mx_MStickerBody_image_container { - display: inline-block; - position: relative; -} - -.mx_MStickerBody_image { - max-width: 100%; - opacity: 0; -} - -.mx_MStickerBody_image_visible { - opacity: 1; -} - -.mx_MStickerBody_placeholder { - position: absolute; - opacity: 1; -} - -.mx_MStickerBody_placeholder_invisible { - transition: 500ms; - opacity: 0; +.mx_MStickerBody_tooltip { + position: absolute; + top: 50%; } diff --git a/res/css/views/messages/_MTextBody.scss b/res/css/views/messages/_MTextBody.scss index fcf397fd2d..93a89ad1b7 100644 --- a/res/css/views/messages/_MTextBody.scss +++ b/res/css/views/messages/_MTextBody.scss @@ -17,8 +17,3 @@ limitations under the License. .mx_MTextBody { white-space: pre-wrap; } - -.mx_MTextBody pre{ - overflow-y: auto; - max-height: 30vh; -} diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 28d432686d..4a46063376 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -75,6 +75,22 @@ limitations under the License. border-radius: 2px; } +.mx_AppTile_mini { + max-width: 960px; + width: 100%; + height: 100%; + margin: 0; + padding: 0; +} + +.mx_AppTile_persistedWrapper { + height: 280px; +} + +.mx_AppTile_mini .mx_AppTile_persistedWrapper { + height: 114px; +} + .mx_AppTileMenuBar { margin: 0; padding: 2px 10px; @@ -126,6 +142,18 @@ limitations under the License. overflow: hidden; } +.mx_AppTileBody_mini { + height: 112px; + width: 100%; + overflow: hidden; +} + +.mx_AppTileBody_mini iframe { + border: none; + width: 100%; + height: 100%; +} + .mx_AppTileBody iframe { width: 100%; height: 280px; diff --git a/res/css/views/rooms/_Autocomplete.scss b/res/css/views/rooms/_Autocomplete.scss index 732ada088b..3e1016f60d 100644 --- a/res/css/views/rooms/_Autocomplete.scss +++ b/res/css/views/rooms/_Autocomplete.scss @@ -69,7 +69,8 @@ flex-flow: wrap; } -.mx_Autocomplete_Completion.selected { +.mx_Autocomplete_Completion.selected, +.mx_Autocomplete_Completion:hover { background: $menu-bg-color; outline: none; } diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index ce2bf9c8a4..f74e2e0850 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -31,7 +31,6 @@ limitations under the License. top: 14px; left: 8px; cursor: pointer; - z-index: 2; } .mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { @@ -187,7 +186,6 @@ limitations under the License. .mx_EventTile_msgOption { float: right; text-align: right; - z-index: 1; position: relative; width: 90px; @@ -290,7 +288,6 @@ limitations under the License. position: absolute; top: 9px; left: 46px; - z-index: 2; cursor: pointer; } @@ -391,6 +388,7 @@ limitations under the License. .mx_EventTile_content .markdown-body pre { overflow-x: overlay; overflow-y: visible; + max-height: 30vh; } .mx_EventTile_content .markdown-body code { @@ -399,6 +397,12 @@ limitations under the License. color: #333; } +.mx_EventTile_pre_container { + // For correct positioning of _copyButton (See TextualBody) + position: relative; +} + +// Inserted adjacent to

 blocks, (See TextualBody)
 .mx_EventTile_copyButton {
     position: absolute;
     display: inline-block;
@@ -412,7 +416,6 @@ limitations under the License.
 }
 
 .mx_EventTile_body pre {
-    position: relative;
     border: 1px solid transparent;
 }
 
@@ -421,7 +424,7 @@ limitations under the License.
     border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter
 }
 
-.mx_EventTile_body pre:hover .mx_EventTile_copyButton
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton
 {
     visibility: visible;
 }
@@ -443,6 +446,7 @@ limitations under the License.
 .mx_EventTile_content .markdown-body h2
 {
     font-size: 1.5em;
+    border-bottom: none ! important; // override GFM
 }
 
 .mx_EventTile_content .markdown-body a {
diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss
index 81f953a23f..26fc70e3c9 100644
--- a/res/css/views/rooms/_MessageComposer.scss
+++ b/res/css/views/rooms/_MessageComposer.scss
@@ -69,6 +69,7 @@ limitations under the License.
     flex: 1;
     display: flex;
     flex-direction: column;
+    cursor: text;
 }
 
 .mx_MessageComposer_input {
@@ -77,12 +78,29 @@ limitations under the License.
     display: flex;
     flex-direction: column;
     min-height: 60px;
-    justify-content: center;
+    justify-content: start;
     align-items: flex-start;
     font-size: 14px;
     margin-right: 6px;
 }
 
+.mx_MessageComposer_editor {
+    width: 100%;
+    max-height: 120px;
+    min-height: 19px;
+    overflow: auto;
+    word-break: break-word;
+}
+
+// FIXME: rather unpleasant hack to get rid of 

margins. +// really we should be mixing in markdown-body from gfm.css instead +.mx_MessageComposer_editor > :first-child { + margin-top: 0 ! important; +} +.mx_MessageComposer_editor > :last-child { + margin-bottom: 0 ! important; +} + @keyframes visualbell { from { background-color: #faa } @@ -93,28 +111,6 @@ limitations under the License. animation: 0.2s visualbell; } -.mx_MessageComposer_input_empty .public-DraftEditorPlaceholder-root { - display: none; -} - -.mx_MessageComposer_input .DraftEditor-root { - width: 100%; - flex: 1; - word-break: break-word; - max-height: 120px; - min-height: 21px; - overflow: auto; -} - -.mx_MessageComposer_input .DraftEditor-root .DraftEditor-editorContainer { - /* Ensure mx_UserPill and mx_RoomPill (see _RichText) are not obscured from the top */ - padding-top: 2px; -} - -.mx_MessageComposer .public-DraftStyleDefault-block { - overflow-x: hidden; -} - .mx_MessageComposer_input blockquote { color: $blockquote-fg-color; margin: 0 0 16px; @@ -122,7 +118,7 @@ limitations under the License. border-left: 4px solid $blockquote-bar-color; } -.mx_MessageComposer_input pre.public-DraftStyleDefault-pre pre { +.mx_MessageComposer_input pre { background-color: $rte-code-bg-color; border-radius: 3px; padding: 10px; diff --git a/res/css/views/rooms/_PinnedEventTile.scss b/res/css/views/rooms/_PinnedEventTile.scss index ca790ef8f0..f7417272b6 100644 --- a/res/css/views/rooms/_PinnedEventTile.scss +++ b/res/css/views/rooms/_PinnedEventTile.scss @@ -25,26 +25,29 @@ limitations under the License. background-color: $event-selected-color; } -.mx_PinnedEventTile .mx_PinnedEventTile_sender { +.mx_PinnedEventTile .mx_PinnedEventTile_sender, +.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { color: #868686; font-size: 0.8em; vertical-align: top; - display: block; + display: inline-block; padding-bottom: 3px; } -.mx_PinnedEventTile .mx_EventTile_content { - margin-left: 50px; - position: relative; - top: 0; - left: 0; +.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { + padding-left: 15px; + display: none; } -.mx_PinnedEventTile .mx_BaseAvatar { +.mx_PinnedEventTile .mx_PinnedEventTile_senderAvatar .mx_BaseAvatar { float: left; margin-right: 10px; } +.mx_PinnedEventTile:hover .mx_PinnedEventTile_timestamp { + display: inline-block; +} + .mx_PinnedEventTile:hover .mx_PinnedEventTile_actions { display: block; } @@ -63,5 +66,12 @@ limitations under the License. .mx_PinnedEventTile_gotoButton { display: inline-block; - font-size: 0.8em; + font-size: 0.7em; // Smaller text to avoid conflicting with the layout } + +.mx_PinnedEventTile_message { + margin-left: 50px; + position: relative; + top: 0; + left: 0; +} \ No newline at end of file diff --git a/res/img/button-refresh.svg b/res/img/button-refresh.svg new file mode 100644 index 0000000000..b4990a2147 --- /dev/null +++ b/res/img/button-refresh.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/res/img/button-text-quote-o-n.svg b/res/img/button-text-block-quote-on.svg similarity index 100% rename from res/img/button-text-quote-o-n.svg rename to res/img/button-text-block-quote-on.svg diff --git a/res/img/button-text-quote.svg b/res/img/button-text-block-quote.svg similarity index 100% rename from res/img/button-text-quote.svg rename to res/img/button-text-block-quote.svg diff --git a/res/img/button-text-bold-o-n.svg b/res/img/button-text-bold-on.svg similarity index 100% rename from res/img/button-text-bold-o-n.svg rename to res/img/button-text-bold-on.svg diff --git a/res/img/button-text-bullet-o-n.svg b/res/img/button-text-bulleted-list-on.svg similarity index 100% rename from res/img/button-text-bullet-o-n.svg rename to res/img/button-text-bulleted-list-on.svg diff --git a/res/img/button-text-bullet.svg b/res/img/button-text-bulleted-list.svg similarity index 100% rename from res/img/button-text-bullet.svg rename to res/img/button-text-bulleted-list.svg diff --git a/res/img/button-text-strike-o-n.svg b/res/img/button-text-deleted-on.svg similarity index 100% rename from res/img/button-text-strike-o-n.svg rename to res/img/button-text-deleted-on.svg diff --git a/res/img/button-text-strike.svg b/res/img/button-text-deleted.svg similarity index 100% rename from res/img/button-text-strike.svg rename to res/img/button-text-deleted.svg diff --git a/res/img/button-text-code-o-n.svg b/res/img/button-text-inline-code-on.svg similarity index 100% rename from res/img/button-text-code-o-n.svg rename to res/img/button-text-inline-code-on.svg diff --git a/res/img/button-text-code.svg b/res/img/button-text-inline-code.svg similarity index 100% rename from res/img/button-text-code.svg rename to res/img/button-text-inline-code.svg diff --git a/res/img/button-text-italic-o-n.svg b/res/img/button-text-italic-on.svg similarity index 100% rename from res/img/button-text-italic-o-n.svg rename to res/img/button-text-italic-on.svg diff --git a/res/img/button-text-numbullet-o-n.svg b/res/img/button-text-numbered-list-on.svg similarity index 100% rename from res/img/button-text-numbullet-o-n.svg rename to res/img/button-text-numbered-list-on.svg diff --git a/res/img/button-text-numbullet.svg b/res/img/button-text-numbered-list.svg similarity index 100% rename from res/img/button-text-numbullet.svg rename to res/img/button-text-numbered-list.svg diff --git a/res/img/button-text-underline-o-n.svg b/res/img/button-text-underlined-on.svg similarity index 100% rename from res/img/button-text-underline-o-n.svg rename to res/img/button-text-underlined-on.svg diff --git a/res/img/button-text-underline.svg b/res/img/button-text-underlined.svg similarity index 100% rename from res/img/button-text-underline.svg rename to res/img/button-text-underlined.svg diff --git a/res/img/e2e-encrypting.svg b/res/img/e2e-encrypting.svg new file mode 100644 index 0000000000..469611cc8d --- /dev/null +++ b/res/img/e2e-encrypting.svg @@ -0,0 +1,12 @@ + + + +48BF5D32-306C-4B20-88EB-24B1F743CAC9 +Created with sketchtool. + + + + + + + diff --git a/res/img/e2e-not_sent.svg b/res/img/e2e-not_sent.svg new file mode 100644 index 0000000000..fca79ae547 --- /dev/null +++ b/res/img/e2e-not_sent.svg @@ -0,0 +1,12 @@ + + + +48BF5D32-306C-4B20-88EB-24B1F743CAC9 +Created with sketchtool. + + + + + + + diff --git a/res/img/icons-share.svg b/res/img/icons-share.svg new file mode 100644 index 0000000000..b27616d5d5 --- /dev/null +++ b/res/img/icons-share.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/img/matrix-m.svg b/res/img/matrix-m.svg new file mode 100644 index 0000000000..ccb1df0fc5 --- /dev/null +++ b/res/img/matrix-m.svg @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/res/img/social/email-1.png b/res/img/social/email-1.png new file mode 100644 index 0000000000..193cb659da Binary files /dev/null and b/res/img/social/email-1.png differ diff --git a/res/img/social/facebook.png b/res/img/social/facebook.png new file mode 100644 index 0000000000..457ef761a1 Binary files /dev/null and b/res/img/social/facebook.png differ diff --git a/res/img/social/linkedin.png b/res/img/social/linkedin.png new file mode 100644 index 0000000000..4c92adb56b Binary files /dev/null and b/res/img/social/linkedin.png differ diff --git a/res/img/social/reddit.png b/res/img/social/reddit.png new file mode 100644 index 0000000000..1310168470 Binary files /dev/null and b/res/img/social/reddit.png differ diff --git a/res/img/social/twitter-2.png b/res/img/social/twitter-2.png new file mode 100644 index 0000000000..9f6e7c602b Binary files /dev/null and b/res/img/social/twitter-2.png differ diff --git a/res/themes/light/css/_base.scss b/res/themes/light/css/_base.scss index 7d7dd8ce2e..72f0d86b42 100644 --- a/res/themes/light/css/_base.scss +++ b/res/themes/light/css/_base.scss @@ -101,6 +101,7 @@ $voip-accept-color: #80f480; $rte-bg-color: #e9e9e9; $rte-code-bg-color: rgba(0, 0, 0, 0.04); $rte-room-pill-color: #aaa; +$rte-group-pill-color: #aaa; $topleftmenu-color: $primary-fg-color; $roomheader-color: $primary-fg-color; diff --git a/scripts/emoji-data-strip.js b/scripts/emoji-data-strip.js index 40156471fe..42bf2ac2de 100644 --- a/scripts/emoji-data-strip.js +++ b/scripts/emoji-data-strip.js @@ -12,6 +12,9 @@ const output = Object.keys(EMOJI_DATA).map( category: datum.category, emoji_order: datum.emoji_order, }; + if (datum.aliases.length > 0) { + newDatum.aliases = datum.aliases; + } if (datum.aliases_ascii.length > 0) { newDatum.aliases_ascii = datum.aliases_ascii; } diff --git a/src/Analytics.js b/src/Analytics.js index 8ffce7077f..d85d635b28 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -39,9 +39,17 @@ function getRedactedHash(hash) { return hash.replace(hashRegex, "#/$1"); } -// Return the current origin and hash separated with a `/`. This does not include query parameters. +// Return the current origin, path and hash separated with a `/`. This does +// not include query parameters. function getRedactedUrl() { - const { origin, pathname, hash } = window.location; + const { origin, hash } = window.location; + let { pathname } = window.location; + + // Redact paths which could contain unexpected PII + if (origin.startsWith('file://')) { + pathname = "//"; + } + return origin + pathname + getRedactedHash(hash); } @@ -191,9 +199,9 @@ class Analytics { this._paq.push(['trackPageView']); } - trackEvent(category, action, name) { + trackEvent(category, action, name, value) { if (this.disabled) return; - this._paq.push(['trackEvent', category, action, name]); + this._paq.push(['trackEvent', category, action, name, value]); } logout() { diff --git a/src/CallHandler.js b/src/CallHandler.js index fd56d7f1b1..acdc3e5122 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -59,7 +59,11 @@ import sdk from './index'; import { _t } from './languageHandler'; import Matrix from 'matrix-js-sdk'; import dis from './dispatcher'; +import SdkConfig from './SdkConfig'; import { showUnknownDeviceDialogForCalls } from './cryptodevices'; +import WidgetUtils from './utils/WidgetUtils'; +import WidgetEchoStore from './stores/WidgetEchoStore'; +import ScalarAuthClient from './ScalarAuthClient'; global.mxCalls = { //room_id: MatrixCall @@ -123,7 +127,7 @@ function _setCallListeners(call) { description: _t( "There are unknown devices in this room: "+ "if you proceed without verifying them, it will be "+ - "possible for someone to eavesdrop on your call." + "possible for someone to eavesdrop on your call.", ), button: _t('Review Devices'), onFinished: function(confirmed) { @@ -246,117 +250,77 @@ function _onAction(payload) { switch (payload.action) { case 'place_call': - if (module.exports.getAnyActiveCall()) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { - title: _t('Existing Call'), - description: _t('You are already in a call.'), - }); - return; // don't allow >1 call to be placed. - } + { + if (module.exports.getAnyActiveCall()) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Existing Call'), + description: _t('You are already in a call.'), + }); + return; // don't allow >1 call to be placed. + } - // if the runtime env doesn't do VoIP, whine. - if (!MatrixClientPeg.get().supportsVoip()) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { - title: _t('VoIP is unsupported'), - description: _t('You cannot place VoIP calls in this browser.'), - }); - return; - } + // if the runtime env doesn't do VoIP, whine. + if (!MatrixClientPeg.get().supportsVoip()) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { + title: _t('VoIP is unsupported'), + description: _t('You cannot place VoIP calls in this browser.'), + }); + return; + } - var room = MatrixClientPeg.get().getRoom(payload.room_id); - if (!room) { - console.error("Room %s does not exist.", payload.room_id); - return; - } + const room = MatrixClientPeg.get().getRoom(payload.room_id); + if (!room) { + console.error("Room %s does not exist.", payload.room_id); + return; + } - var members = room.getJoinedMembers(); - if (members.length <= 1) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { - description: _t('You cannot place a call with yourself.'), - }); - return; - } else if (members.length === 2) { - console.log("Place %s call in %s", payload.type, payload.room_id); - const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); - placeCall(call); - } else { // > 2 - dis.dispatch({ - action: "place_conference_call", - room_id: payload.room_id, - type: payload.type, - remote_element: payload.remote_element, - local_element: payload.local_element, - }); + const members = room.getJoinedMembers(); + if (members.length <= 1) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { + description: _t('You cannot place a call with yourself.'), + }); + return; + } else if (members.length === 2) { + console.log("Place %s call in %s", payload.type, payload.room_id); + const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); + placeCall(call); + } else { // > 2 + dis.dispatch({ + action: "place_conference_call", + room_id: payload.room_id, + type: payload.type, + remote_element: payload.remote_element, + local_element: payload.local_element, + }); + } } break; case 'place_conference_call': console.log("Place conference call in %s", payload.room_id); - if (!ConferenceHandler) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, { - description: _t('Conference calls are not supported in this client'), - }); - } else if (!MatrixClientPeg.get().supportsVoip()) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { - title: _t('VoIP is unsupported'), - description: _t('You cannot place VoIP calls in this browser.'), - }); - } else if (MatrixClientPeg.get().isRoomEncrypted(payload.room_id)) { - // Conference calls are implemented by sending the media to central - // server which combines the audio from all the participants together - // into a single stream. This is incompatible with end-to-end encryption - // because a central server would be decrypting the audio for each - // participant. - // Therefore we disable conference calling in E2E rooms. - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Handler', 'Conference calls unsupported e2e', ErrorDialog, { - description: _t('Conference calls are not supported in encrypted rooms'), - }); - } else { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, { - title: _t('Warning!'), - description: _t('Conference calling is in development and may not be reliable.'), - onFinished: (confirm)=>{ - if (confirm) { - ConferenceHandler.createNewMatrixCall( - MatrixClientPeg.get(), payload.room_id, - ).done(function(call) { - placeCall(call); - }, function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - console.error("Conference call failed: " + err); - Modal.createTrackedDialog('Call Handler', 'Failed to set up conference call', ErrorDialog, { - title: _t('Failed to set up conference call'), - description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''), - }); - }); - } - }, - }); - } + _startCallApp(payload.room_id, payload.type); break; case 'incoming_call': - if (module.exports.getAnyActiveCall()) { - // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. - // we avoid rejecting with "busy" in case the user wants to answer it on a different device. - // in future we could signal a "local busy" as a warning to the caller. - // see https://github.com/vector-im/vector-web/issues/1964 - return; - } + { + if (module.exports.getAnyActiveCall()) { + // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. + // we avoid rejecting with "busy" in case the user wants to answer it on a different device. + // in future we could signal a "local busy" as a warning to the caller. + // see https://github.com/vector-im/vector-web/issues/1964 + return; + } - // if the runtime env doesn't do VoIP, stop here. - if (!MatrixClientPeg.get().supportsVoip()) { - return; - } + // if the runtime env doesn't do VoIP, stop here. + if (!MatrixClientPeg.get().supportsVoip()) { + return; + } - var call = payload.call; - _setCallListeners(call); - _setCallState(call, call.roomId, "ringing"); + const call = payload.call; + _setCallListeners(call); + _setCallState(call, call.roomId, "ringing"); + } break; case 'hangup': if (!calls[payload.room_id]) { @@ -378,6 +342,112 @@ function _onAction(payload) { break; } } + +async function _startCallApp(roomId, type) { + // check for a working intgrations 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. + const scalarClient = new ScalarAuthClient(); + let haveScalar = false; + try { + await scalarClient.connect(); + haveScalar = scalarClient.hasCredentials(); + } catch (e) { + // fall through + } + if (!haveScalar) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, { + title: _t('Could not connect to the integration server'), + description: _t('A conference call could not be started because the intgrations server is not available'), + }); + return; + } + + dis.dispatch({ + action: 'appsDrawer', + show: true, + }); + + const room = MatrixClientPeg.get().getRoom(roomId); + const currentRoomWidgets = WidgetUtils.getRoomWidgets(room); + + if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, 'jitsi')) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { + title: _t('Call in Progress'), + description: _t('A call is currently being placed!'), + }); + return; + } + + const currentJitsiWidgets = currentRoomWidgets.filter((ev) => { + return ev.getContent().type === 'jitsi'; + }); + if (currentJitsiWidgets.length > 0) { + console.warn( + "Refusing to start conference call widget in " + roomId + + " a conference call widget is already present", + ); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, { + title: _t('Call in Progress'), + description: _t('A call is already in progress!'), + }); + return; + } + + // This inherits its poor naming from the field of the same name that goes into + // the event. It's just a random string to make the Jitsi URLs unique. + const widgetSessionId = Math.random().toString(36).substring(2); + const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId; + // NB. we can't just encodeURICompoent all of these because the $ signs need to be there + // (but currently the only thing that needs encoding is the confId) + const queryString = [ + 'confId='+encodeURIComponent(confId), + 'isAudioConf='+(type === 'voice' ? 'true' : 'false'), + 'displayName=$matrix_display_name', + 'avatarUrl=$matrix_avatar_url', + 'email=$matrix_user_id', + ].join('&'); + + let widgetUrl; + if (SdkConfig.get().integrations_jitsi_widget_url) { + // Try this config key. This probably isn't ideal as a way of discovering this + // URL, but this will at least allow the integration manager to not be hardcoded. + widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString; + } else { + widgetUrl = SdkConfig.get().integrations_rest_url + '/widgets/jitsi.html?' + queryString; + } + + const widgetData = { widgetSessionId }; + + const widgetId = ( + 'jitsi_' + + MatrixClientPeg.get().credentials.userId + + '_' + + Date.now() + ); + + WidgetUtils.setRoomWidget(roomId, widgetId, 'jitsi', widgetUrl, 'Jitsi', widgetData).then(() => { + console.log('Jitsi widget added'); + }).catch((e) => { + if (e.errcode === 'M_FORBIDDEN') { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + + Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { + title: _t('Permission Required'), + description: _t("You do not have permission to start a conference call in this room"), + }); + } + console.error(e); + }); +} + // FIXME: Nasty way of making sure we only register // with the dispatcher once if (!global.mxCallHandler) { @@ -412,6 +482,24 @@ const callHandler = { return null; }, + /** + * The conference handler is a module that deals with implementation-specific + * multi-party calling implementations. Riot passes in its own which creates + * a one-to-one call with a freeswitch conference bridge. As of July 2018, + * the de-facto way of conference calling is a Jitsi widget, so this is + * deprecated. It reamins here for two reasons: + * 1. So Riot still supports joining existing freeswitch conference calls + * (but doesn't support creating them). After a transition period, we can + * remove support for joining them too. + * 2. To hide the one-to-one rooms that old-style conferencing creates. This + * is much harder to remove: probably either we make Riot leave & forget these + * rooms after we remove support for joining freeswitch conferences, or we + * accept that random rooms with cryptic users will suddently appear for + * anyone who's ever used conference calling, or we are stuck with this + * code forever. + * + * @param {object} confHandler The conference handler object + */ setConferenceHandler: function(confHandler) { ConferenceHandler = confHandler; }, diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js index cdc5c61921..2330f86b99 100644 --- a/src/CallMediaHandler.js +++ b/src/CallMediaHandler.js @@ -22,34 +22,44 @@ export default { // Only needed for Electron atm, though should work in modern browsers // once permission has been granted to the webapp return navigator.mediaDevices.enumerateDevices().then(function(devices) { - const audioIn = []; - const videoIn = []; + const audiooutput = []; + const audioinput = []; + const videoinput = []; if (devices.some((device) => !device.label)) return false; devices.forEach((device) => { switch (device.kind) { - case 'audioinput': audioIn.push(device); break; - case 'videoinput': videoIn.push(device); break; + case 'audiooutput': audiooutput.push(device); break; + case 'audioinput': audioinput.push(device); break; + case 'videoinput': videoinput.push(device); break; } }); // console.log("Loaded WebRTC Devices", mediaDevices); return { - audioinput: audioIn, - videoinput: videoIn, + audiooutput, + audioinput, + videoinput, }; }, (error) => { console.log('Unable to refresh WebRTC Devices: ', error); }); }, loadDevices: function() { + const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); + Matrix.setMatrixCallAudioOutput(audioOutDeviceId); Matrix.setMatrixCallAudioInput(audioDeviceId); Matrix.setMatrixCallVideoInput(videoDeviceId); }, + setAudioOutput: function(deviceId) { + SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); + Matrix.setMatrixCallAudioOutput(deviceId); + }, + setAudioInput: function(deviceId) { SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); Matrix.setMatrixCallAudioInput(deviceId); diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js index 2757c5bd3d..0164e6c4cd 100644 --- a/src/ComposerHistoryManager.js +++ b/src/ComposerHistoryManager.js @@ -15,46 +15,44 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {ContentState, convertToRaw, convertFromRaw} from 'draft-js'; -import * as RichText from './RichText'; -import Markdown from './Markdown'; +import { Value } from 'slate'; + import _clamp from 'lodash/clamp'; -type MessageFormat = 'html' | 'markdown'; +type MessageFormat = 'rich' | 'markdown'; class HistoryItem { - // Keeping message for backwards-compatibility - message: string; - rawContentState: RawDraftContentState; - format: MessageFormat = 'html'; + // 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(contentState: ?ContentState, format: ?MessageFormat) { - this.rawContentState = contentState ? convertToRaw(contentState) : null; + constructor(value: ?Value, format: ?MessageFormat) { + this.value = value; this.format = format; } - toContentState(outputFormat: MessageFormat): ContentState { - const contentState = convertFromRaw(this.rawContentState); - if (outputFormat === 'markdown') { - if (this.format === 'html') { - return ContentState.createFromText(RichText.stateToMarkdown(contentState)); - } - } else { - if (this.format === 'markdown') { - return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML()); - } - } - // history item has format === outputFormat - return contentState; + 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 = []; prefix: string; - lastIndex: number = 0; - currentIndex: number = 0; + 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; @@ -62,23 +60,28 @@ export default class ComposerHistoryManager { // TODO: Performance issues? let item; for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { - this.history.push( - Object.assign(new HistoryItem(), JSON.parse(item)), - ); + 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(contentState: ContentState, format: MessageFormat) { - const item = new HistoryItem(contentState, format); + save(value: Value, format: MessageFormat) { + const item = new HistoryItem(value, format); this.history.push(item); - this.currentIndex = this.lastIndex + 1; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item)); + this.currentIndex = this.history.length; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); } - getItem(offset: number, format: MessageFormat): ?ContentState { - this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1); - const item = this.history[this.currentIndex]; - return item ? item.toContentState(format) : null; + getItem(offset: number): ?HistoryItem { + this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + return this.history[this.currentIndex]; } } diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 7fe625f8b9..fd21977108 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -243,6 +243,7 @@ function uploadFile(matrixClient, roomId, file, progressHandler) { const blob = new Blob([encryptResult.data]); return matrixClient.uploadContent(blob, { progressHandler: progressHandler, + includeFilename: false, }).then(function(url) { // If the attachment is encrypted then bundle the URL along // with the information needed to decrypt the attachment and diff --git a/src/DecryptionFailureTracker.js b/src/DecryptionFailureTracker.js new file mode 100644 index 0000000000..b02a5e937b --- /dev/null +++ b/src/DecryptionFailureTracker.js @@ -0,0 +1,202 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class DecryptionFailure { + constructor(failedEventId, errorCode) { + this.failedEventId = failedEventId; + this.errorCode = errorCode; + this.ts = Date.now(); + } +} + +export class DecryptionFailureTracker { + // Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list + // is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did + // are accumulated in `failureCounts`. + failures = []; + + // A histogram of the number of failures that will be tracked at the next tracking + // interval, split by failure error code. + failureCounts = { + // [errorCode]: 42 + }; + + // Event IDs of failures that were tracked previously + trackedEventHashMap = { + // [eventId]: true + }; + + // Set to an interval ID when `start` is called + checkInterval = null; + trackInterval = null; + + // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. + static TRACK_INTERVAL_MS = 60000; + + // Call `checkFailures` every `CHECK_INTERVAL_MS`. + static CHECK_INTERVAL_MS = 5000; + + // Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before counting + // the failure in `failureCounts`. + static GRACE_PERIOD_MS = 60000; + + /** + * Create a new DecryptionFailureTracker. + * + * Call `eventDecrypted(event, err)` on this instance when an event is decrypted. + * + * Call `start()` to start the tracker, and `stop()` to stop tracking. + * + * @param {function} fn The tracking function, which will be called when failures + * are tracked. The function should have a signature `(count, trackedErrorCode) => {...}`, + * where `count` is the number of failures and `errorCode` matches the `.code` of + * provided DecryptionError errors (by default, unless `errorCodeMapFn` is specified. + * @param {function?} errorCodeMapFn The function used to map error codes to the + * trackedErrorCode. If not provided, the `.code` of errors will be used. + */ + constructor(fn, errorCodeMapFn) { + if (!fn || typeof fn !== 'function') { + throw new Error('DecryptionFailureTracker requires tracking function'); + } + + if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') { + throw new Error('DecryptionFailureTracker second constructor argument should be a function'); + } + + this._trackDecryptionFailure = fn; + this._mapErrorCode = errorCodeMapFn; + } + + // loadTrackedEventHashMap() { + // this.trackedEventHashMap = JSON.parse(localStorage.getItem('mx-decryption-failure-event-id-hashes')) || {}; + // } + + // saveTrackedEventHashMap() { + // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap)); + // } + + eventDecrypted(e, err) { + if (err) { + this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code)); + } else { + // Could be an event in the failures, remove it + this.removeDecryptionFailuresForEvent(e); + } + } + + addDecryptionFailure(failure) { + this.failures.push(failure); + } + + removeDecryptionFailuresForEvent(e) { + this.failures = this.failures.filter((f) => f.failedEventId !== e.getId()); + } + + /** + * Start checking for and tracking failures. + */ + start() { + this.checkInterval = setInterval( + () => this.checkFailures(Date.now()), + DecryptionFailureTracker.CHECK_INTERVAL_MS, + ); + + this.trackInterval = setInterval( + () => this.trackFailures(), + DecryptionFailureTracker.TRACK_INTERVAL_MS, + ); + } + + /** + * Clear state and stop checking for and tracking failures. + */ + stop() { + clearInterval(this.checkInterval); + clearInterval(this.trackInterval); + + this.failures = []; + this.failureCounts = {}; + } + + /** + * Mark failures that occured before nowTs - GRACE_PERIOD_MS as failures that should be + * tracked. Only mark one failure per event ID. + * @param {number} nowTs the timestamp that represents the time now. + */ + checkFailures(nowTs) { + const failuresGivenGrace = []; + const failuresNotReady = []; + while (this.failures.length > 0) { + const f = this.failures.shift(); + if (nowTs > f.ts + DecryptionFailureTracker.GRACE_PERIOD_MS) { + failuresGivenGrace.push(f); + } else { + failuresNotReady.push(f); + } + } + this.failures = failuresNotReady; + + // Only track one failure per event + const dedupedFailuresMap = failuresGivenGrace.reduce( + (map, failure) => { + if (!this.trackedEventHashMap[failure.failedEventId]) { + return map.set(failure.failedEventId, failure); + } else { + return map; + } + }, + // Use a map to preseve key ordering + new Map(), + ); + + const trackedEventIds = [...dedupedFailuresMap.keys()]; + + this.trackedEventHashMap = trackedEventIds.reduce( + (result, eventId) => ({...result, [eventId]: true}), + this.trackedEventHashMap, + ); + + // Commented out for now for expediency, we need to consider unbound nature of storing + // this in localStorage + // this.saveTrackedEventHashMap(); + + const dedupedFailures = dedupedFailuresMap.values(); + + this._aggregateFailures(dedupedFailures); + } + + _aggregateFailures(failures) { + for (const failure of failures) { + const errorCode = failure.errorCode; + this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1; + } + } + + /** + * If there are failures that should be tracked, call the given trackDecryptionFailure + * function with the number of failures that should be tracked. + */ + trackFailures() { + for (const errorCode of Object.keys(this.failureCounts)) { + if (this.failureCounts[errorCode] > 0) { + const trackedErrorCode = this._mapErrorCode ? this._mapErrorCode(errorCode) : errorCode; + + this._trackDecryptionFailure(this.failureCounts[errorCode], trackedErrorCode); + this.failureCounts[errorCode] = 0; + } + } + } +} diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index 792fd73733..ea7eeba756 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -18,6 +18,7 @@ import URL from 'url'; import dis from './dispatcher'; import IntegrationManager from './IntegrationManager'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; +import ActiveWidgetStore from './stores/ActiveWidgetStore'; const WIDGET_API_VERSION = '0.0.1'; // Current API version const SUPPORTED_WIDGET_API_VERSIONS = [ @@ -155,6 +156,14 @@ export default class FromWidgetPostMessageApi { const integType = (data && data.integType) ? data.integType : null; const integId = (data && data.integId) ? data.integId : null; IntegrationManager.open(integType, integId); + } 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; + const val = data.value; + + if (ActiveWidgetStore.widgetHasCapability(widgetId, 'm.always_on_screen')) { + ActiveWidgetStore.setWidgetPersistence(widgetId, val); + } } else { console.warn('Widget postMessage event unhandled'); this.sendError(event, {message: 'The postMessage was unhandled'}); diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index 91380b6eed..532ee23c25 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from 'react'; import Modal from './Modal'; import sdk from './'; import MultiInviter from './utils/MultiInviter'; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 7ca404be31..b6a2bd0acb 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -112,7 +112,6 @@ export function charactersToImageNode(alt, useSvg, ...unicode) { />; } - export function processHtmlForSending(html: string): string { const contentDiv = document.createElement('div'); contentDiv.innerHTML = html; @@ -130,13 +129,6 @@ export function processHtmlForSending(html: string): string { if (i !== contentDiv.children.length - 1) { contentHTML += '
'; } - } else if (element.tagName.toLowerCase() === 'pre') { - // Replace "
\n" with "\n" within `

` tags because the 
is - // redundant. This is a workaround for a bug in draft-js-export-html: - // https://github.com/sstur/draft-js-export-html/issues/62 - contentHTML += '
' +
-                element.innerHTML.replace(/
\n/g, '\n').trim() + - '
'; } else { const temp = document.createElement('div'); temp.appendChild(element.cloneNode(true)); @@ -176,6 +168,99 @@ export function isUrlPermitted(inputUrl) { } } +const transformTags = { // custom to matrix + // add blank targets to all hyperlinks except vector URLs + 'a': function(tagName, attribs) { + if (attribs.href) { + attribs.target = '_blank'; // by default + + let m; + // FIXME: horrible duplication with linkify-matrix + m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); + if (m) { + attribs.href = m[1]; + delete attribs.target; + } else { + m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); + if (m) { + const entity = m[1]; + switch (entity[0]) { + case '@': + attribs.href = '#/user/' + entity; + break; + case '+': + attribs.href = '#/group/' + entity; + break; + case '#': + case '!': + attribs.href = '#/room/' + entity; + break; + } + delete attribs.target; + } + } + } + attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ + return { tagName, attribs }; + }, + 'img': function(tagName, attribs) { + // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag + // because transformTags is used _before_ we filter by allowedSchemesByTag and + // we don't want to allow images with `https?` `src`s. + if (!attribs.src || !attribs.src.startsWith('mxc://')) { + return { tagName, attribs: {}}; + } + attribs.src = MatrixClientPeg.get().mxcUrlToHttp( + attribs.src, + attribs.width || 800, + attribs.height || 600, + ); + return { tagName, attribs }; + }, + 'code': function(tagName, attribs) { + if (typeof attribs.class !== 'undefined') { + // Filter out all classes other than ones starting with language- for syntax highlighting. + const classes = attribs.class.split(/\s+/).filter(function(cl) { + return cl.startsWith('language-'); + }); + attribs.class = classes.join(' '); + } + return { tagName, attribs }; + }, + '*': function(tagName, attribs) { + // Delete any style previously assigned, style is an allowedTag for font and span + // because attributes are stripped after transforming + delete attribs.style; + + // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS + // equivalents + const customCSSMapper = { + 'data-mx-color': 'color', + 'data-mx-bg-color': 'background-color', + // $customAttributeKey: $cssAttributeKey + }; + + let style = ""; + Object.keys(customCSSMapper).forEach((customAttributeKey) => { + const cssAttributeKey = customCSSMapper[customAttributeKey]; + const customAttributeValue = attribs[customAttributeKey]; + if (customAttributeValue && + typeof customAttributeValue === 'string' && + COLOR_REGEX.test(customAttributeValue) + ) { + style += cssAttributeKey + ":" + customAttributeValue + ";"; + delete attribs[customAttributeKey]; + } + }); + + if (style) { + attribs.style = style; + } + + return { tagName, attribs }; + }, +}; + const sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring @@ -199,95 +284,14 @@ const sanitizeHtmlParams = { allowedSchemes: PERMITTED_URL_SCHEMES, allowProtocolRelative: false, + transformTags, +}; - transformTags: { // custom to matrix - // add blank targets to all hyperlinks except vector URLs - 'a': function(tagName, attribs) { - if (attribs.href) { - attribs.target = '_blank'; // by default - - let m; - // FIXME: horrible duplication with linkify-matrix - m = attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN); - if (m) { - attribs.href = m[1]; - delete attribs.target; - } else { - m = attribs.href.match(linkifyMatrix.MATRIXTO_URL_PATTERN); - if (m) { - const entity = m[1]; - if (entity[0] === '@') { - attribs.href = '#/user/' + entity; - } else if (entity[0] === '#' || entity[0] === '!') { - attribs.href = '#/room/' + entity; - } - delete attribs.target; - } - } - } - attribs.rel = 'noopener'; // https://mathiasbynens.github.io/rel-noopener/ - return { tagName: tagName, attribs: attribs }; - }, - 'img': function(tagName, attribs) { - // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag - // because transformTags is used _before_ we filter by allowedSchemesByTag and - // we don't want to allow images with `https?` `src`s. - if (!attribs.src || !attribs.src.startsWith('mxc://')) { - return { tagName, attribs: {}}; - } - attribs.src = MatrixClientPeg.get().mxcUrlToHttp( - attribs.src, - attribs.width || 800, - attribs.height || 600, - ); - return { tagName: tagName, attribs: attribs }; - }, - 'code': function(tagName, attribs) { - if (typeof attribs.class !== 'undefined') { - // Filter out all classes other than ones starting with language- for syntax highlighting. - const classes = attribs.class.split(/\s+/).filter(function(cl) { - return cl.startsWith('language-'); - }); - attribs.class = classes.join(' '); - } - return { - tagName: tagName, - attribs: attribs, - }; - }, - '*': function(tagName, attribs) { - // Delete any style previously assigned, style is an allowedTag for font and span - // because attributes are stripped after transforming - delete attribs.style; - - // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS - // equivalents - const customCSSMapper = { - 'data-mx-color': 'color', - 'data-mx-bg-color': 'background-color', - // $customAttributeKey: $cssAttributeKey - }; - - let style = ""; - Object.keys(customCSSMapper).forEach((customAttributeKey) => { - const cssAttributeKey = customCSSMapper[customAttributeKey]; - const customAttributeValue = attribs[customAttributeKey]; - if (customAttributeValue && - typeof customAttributeValue === 'string' && - COLOR_REGEX.test(customAttributeValue) - ) { - style += cssAttributeKey + ":" + customAttributeValue + ";"; - delete attribs[customAttributeKey]; - } - }); - - if (style) { - attribs.style = style; - } - - return { tagName: tagName, attribs: attribs }; - }, - }, +// this is the same as the above except with less rewriting +const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams); +composerSanitizeHtmlParams.transformTags = { + 'code': transformTags['code'], + '*': transformTags['*'], }; class BaseHighlighter { @@ -402,21 +406,30 @@ class TextHighlighter extends BaseHighlighter { } - /* turn a matrix event body into html - * - * content: 'content' of the MatrixEvent - * - * highlights: optional list of words to highlight, ordered by longest word first - * - * opts.highlightLink: optional href to add to highlighted words - * opts.disableBigEmoji: optional argument to disable the big emoji class. - * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing - */ +/* turn a matrix event body into html + * + * content: 'content' of the MatrixEvent + * + * highlights: optional list of words to highlight, ordered by longest word first + * + * opts.highlightLink: optional href to add to highlighted words + * opts.disableBigEmoji: optional argument to disable the big emoji class. + * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing + * opts.returnString: return an HTML string rather than JSX elements + * opts.emojiOne: optional param to do emojiOne (default true) + * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer + */ export function bodyToHtml(content, highlights, opts={}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; + const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne; let bodyHasEmoji = false; + let sanitizeParams = sanitizeHtmlParams; + if (opts.forComposerQuote) { + sanitizeParams = composerSanitizeHtmlParams; + } + let strippedBody; let safeBody; let isDisplayedWithHtml; @@ -428,10 +441,10 @@ export function bodyToHtml(content, highlights, opts={}) { if (highlights && highlights.length > 0) { const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); const safeHighlights = highlights.map(function(highlight) { - return sanitizeHtml(highlight, sanitizeHtmlParams); + return sanitizeHtml(highlight, sanitizeParams); }); - // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure. - sanitizeHtmlParams.textFilter = function(safeText) { + // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. + sanitizeParams.textFilter = function(safeText) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); }; } @@ -440,19 +453,20 @@ export function bodyToHtml(content, highlights, opts={}) { if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody); strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body; - bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body); - + if (doEmojiOne) { + bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body); + } // Only generate safeBody if the message was sent as org.matrix.custom.html if (isHtmlMessage) { isDisplayedWithHtml = true; - safeBody = sanitizeHtml(formattedBody, sanitizeHtmlParams); + safeBody = sanitizeHtml(formattedBody, sanitizeParams); } else { // ... or if there are emoji, which we insert as HTML alongside the // escaped plaintext body. if (bodyHasEmoji) { isDisplayedWithHtml = true; - safeBody = sanitizeHtml(escape(strippedBody), sanitizeHtmlParams); + safeBody = sanitizeHtml(escape(strippedBody), sanitizeParams); } } @@ -463,7 +477,11 @@ export function bodyToHtml(content, highlights, opts={}) { safeBody = unicodeToImage(safeBody); } } finally { - delete sanitizeHtmlParams.textFilter; + delete sanitizeParams.textFilter; + } + + if (opts.returnString) { + return isDisplayedWithHtml ? safeBody : strippedBody; } let emojiBody = false; diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 7378e982ef..f32f105889 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -30,6 +30,7 @@ import DMRoomMap from './utils/DMRoomMap'; import RtsClient from './RtsClient'; import Modal from './Modal'; import sdk from './index'; +import ActiveWidgetStore from './stores/ActiveWidgetStore'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -436,6 +437,7 @@ async function startMatrixClient() { UserActivity.start(); Presence.start(); DMRoomMap.makeShared().start(); + ActiveWidgetStore.start(); await MatrixClientPeg.start(); @@ -488,6 +490,7 @@ export function stopMatrixClient() { Notifier.stop(); UserActivity.stop(); Presence.stop(); + ActiveWidgetStore.stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop(); const cli = MatrixClientPeg.get(); if (cli) { diff --git a/src/Markdown.js b/src/Markdown.js index aa1c7e45b1..acfea52100 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -102,6 +102,16 @@ export default class Markdown { // (https://github.com/vector-im/riot-web/issues/3154) softbreak: '
', }); + + // Trying to strip out the wrapping

causes a lot more complication + // than it's worth, i think. For instance, this code will go and strip + // out any

tag (no matter where it is in the tree) which doesn't + // contain \n's. + // On the flip side,

s are quite opionated and restricted on where + // you can nest them. + // + // Let's try sending with

s anyway for now, though. + const real_paragraph = renderer.paragraph; renderer.paragraph = function(node, entering) { @@ -115,15 +125,20 @@ export default class Markdown { } }; + renderer.html_inline = html_if_tag_allowed; + renderer.html_block = function(node) { +/* // as with `paragraph`, we only insert line breaks // if there are multiple lines in the markdown. const isMultiLine = is_multi_line(node); - if (isMultiLine) this.cr(); +*/ html_if_tag_allowed.call(this, node); +/* if (isMultiLine) this.cr(); +*/ }; return renderer.render(this.parsed); @@ -133,7 +148,10 @@ export default class Markdown { * Render the markdown message to plain text. That is, essentially * just remove any backslashes escaping what would otherwise be * markdown syntax - * (to fix https://github.com/vector-im/riot-web/issues/2870) + * (to fix https://github.com/vector-im/riot-web/issues/2870). + * + * N.B. this does **NOT** render arbitrary MD to plain text - only MD + * which has no formatting. Otherwise it emits HTML(!). */ toPlaintext() { const renderer = new commonmark.HtmlRenderer({safe: false}); @@ -156,6 +174,7 @@ export default class Markdown { } } }; + renderer.html_block = function(node) { this.lit(node.literal); if (is_multi_line(node) && node.next) this.lit('\n\n'); diff --git a/src/Notifier.js b/src/Notifier.js index b823c4df05..80e8be1084 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -170,15 +170,15 @@ const Notifier = { value: true, }); }); - // clear the notifications_hidden flag, so that if notifications are - // disabled again in the future, we will show the banner again. - this.setToolbarHidden(true); } else { dis.dispatch({ action: "notifier_enabled", value: false, }); } + // set the notifications_hidden flag, as the user has knowingly interacted + // with the setting we shouldn't nag them any further + this.setToolbarHidden(true); }, isEnabled: function() { diff --git a/src/RichText.js b/src/RichText.js index 12274ee9f3..3e8f834da6 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -1,307 +1,40 @@ -import React from 'react'; -import { - Editor, - EditorState, - Modifier, - ContentState, - ContentBlock, - convertFromHTML, - DefaultDraftBlockRenderMap, - DefaultDraftInlineStyle, - CompositeDecorator, - SelectionState, - Entity, -} from 'draft-js'; -import * as sdk from './index'; +/* +Copyright 2015 - 2017 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +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 * as emojione from 'emojione'; -import {stateToHTML} from 'draft-js-export-html'; -import {SelectionRange} from "./autocomplete/Autocompleter"; -import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown'; -const MARKDOWN_REGEX = { - LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, - ITALIC: /([\*_])([\w\s]+?)\1/g, - BOLD: /([\*_])\1([\w\s]+?)\1\1/g, - HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g, - CODE: /`[^`]*`/g, - STRIKETHROUGH: /~{2}[^~]*~{2}/g, -}; -const USERNAME_REGEX = /@\S+:\S+/g; -const ROOM_REGEX = /#\S+:\S+/g; -const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g'); +export function unicodeToEmojiUri(str) { + const mappedUnicode = emojione.mapUnicodeToShort(); -const ZWS_CODE = 8203; -const ZWS = String.fromCharCode(ZWS_CODE); // zero width space -export function stateToMarkdown(state) { - return __stateToMarkdown(state) - .replace( - ZWS, // draft-js-export-markdown adds these - ''); // this is *not* a zero width space, trust me :) -} - -export const contentStateToHTML = (contentState: ContentState) => { - return stateToHTML(contentState, { - inlineStyles: { - UNDERLINE: { - element: 'u', - }, - }, - }); -}; - -export function htmlToContentState(html: string): ContentState { - const blockArray = convertFromHTML(html).contentBlocks; - return ContentState.createFromBlockArray(blockArray); -} - -function unicodeToEmojiUri(str) { - let replaceWith, unicode, alt; - if ((!emojione.unicodeAlt) || (emojione.sprites)) { - // if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames - const mappedUnicode = emojione.mapUnicodeToShort(); - } - - str = str.replace(emojione.regUnicode, function(unicodeChar) { - if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) { - // if the unicodeChar doesnt exist just return the entire match + // remove any zero width joiners/spaces used in conjugate emojis as the emojione URIs don't contain them + return str.replace(emojione.regUnicode, function(unicodeChar) { + if ((typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap))) { + // if the unicodeChar doesn't exist just return the entire match return unicodeChar; } else { - // Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below - if (unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') { - unicodeChar = unicodeChar[0]; - } - // get the unicode codepoint from the actual char - unicode = emojione.jsEscapeMap[unicodeChar]; + const unicode = emojione.jsEscapeMap[unicodeChar]; - return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam; + const short = mappedUnicode[unicode]; + const fname = emojione.emojioneList[short].fname; + + return emojione.imagePathSVG+fname+'.svg'+emojione.cacheBustParam; } }); - - return str; -} - -/** - * Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end) - * From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html - */ -function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) { - const text = contentBlock.getText(); - let matchArr, start; - while ((matchArr = regex.exec(text)) !== null) { - start = matchArr.index; - callback(start, start + matchArr[0].length); - } -} - -// Workaround for https://github.com/facebook/draft-js/issues/414 -const emojiDecorator = { - strategy: (contentState, contentBlock, callback) => { - findWithRegex(EMOJI_REGEX, contentBlock, callback); - }, - component: (props) => { - const uri = unicodeToEmojiUri(props.children[0].props.text); - const shortname = emojione.toShort(props.children[0].props.text); - const style = { - display: 'inline-block', - width: '1em', - maxHeight: '1em', - background: `url(${uri})`, - backgroundSize: 'contain', - backgroundPosition: 'center center', - overflow: 'hidden', - }; - return ({ props.children }); - }, -}; - -/** - * Returns a composite decorator which has access to provided scope. - */ -export function getScopedRTDecorators(scope: any): CompositeDecorator { - return [emojiDecorator]; -} - -export function getScopedMDDecorators(scope: any): CompositeDecorator { - const markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( - (style) => ({ - strategy: (contentState, contentBlock, callback) => { - return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); - }, - component: (props) => ( - - { props.children } - - ), - })); - - markdownDecorators.push({ - strategy: (contentState, contentBlock, callback) => { - return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback); - }, - component: (props) => ( - - { props.children } - - ), - }); - // markdownDecorators.push(emojiDecorator); - // TODO Consider renabling "syntax highlighting" when we can do it properly - return [emojiDecorator]; -} - -/** - * Passes rangeToReplace to modifyFn and replaces it in contentState with the result. - */ -export function modifyText(contentState: ContentState, rangeToReplace: SelectionState, - modifyFn: (text: string) => string, inlineStyle, entityKey): ContentState { - let getText = (key) => contentState.getBlockForKey(key).getText(), - startKey = rangeToReplace.getStartKey(), - startOffset = rangeToReplace.getStartOffset(), - endKey = rangeToReplace.getEndKey(), - endOffset = rangeToReplace.getEndOffset(), - text = ""; - - - for (let currentKey = startKey; - currentKey && currentKey !== endKey; - currentKey = contentState.getKeyAfter(currentKey)) { - const blockText = getText(currentKey); - text += blockText.substring(startOffset, blockText.length); - - // from now on, we'll take whole blocks - startOffset = 0; - } - - // add remaining part of last block - text += getText(endKey).substring(startOffset, endOffset); - - return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); -} - -/** - * Computes the plaintext offsets of the given SelectionState. - * Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc) - * Used by autocomplete to show completions when the current selection lies within, or at the edges of a command. - */ -export function selectionStateToTextOffsets(selectionState: SelectionState, - contentBlocks: Array): {start: number, end: number} { - let offset = 0, start = 0, end = 0; - for (const block of contentBlocks) { - if (selectionState.getStartKey() === block.getKey()) { - start = offset + selectionState.getStartOffset(); - } - if (selectionState.getEndKey() === block.getKey()) { - end = offset + selectionState.getEndOffset(); - break; - } - offset += block.getLength(); - } - - return { - start, - end, - }; -} - -export function textOffsetsToSelectionState({start, end}: SelectionRange, - contentBlocks: Array): SelectionState { - let selectionState = SelectionState.createEmpty(); - // Subtract block lengths from `start` and `end` until they are less than the current - // block length (accounting for the NL at the end of each block). Set them to -1 to - // indicate that the corresponding selection state has been determined. - for (const block of contentBlocks) { - const blockLength = block.getLength(); - // -1 indicating that the position start position has been found - if (start !== -1) { - if (start < blockLength + 1) { - selectionState = selectionState.merge({ - anchorKey: block.getKey(), - anchorOffset: start, - }); - start = -1; // selection state for the start calculated - } else { - start -= blockLength + 1; // +1 to account for newline between blocks - } - } - // -1 indicating that the position end position has been found - if (end !== -1) { - if (end < blockLength + 1) { - selectionState = selectionState.merge({ - focusKey: block.getKey(), - focusOffset: end, - }); - end = -1; // selection state for the end calculated - } else { - end -= blockLength + 1; // +1 to account for newline between blocks - } - } - } - return selectionState; -} - -// modified version of https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-emoji-plugin/src/modifiers/attachImmutableEntitiesToEmojis.js -export function attachImmutableEntitiesToEmoji(editorState: EditorState): EditorState { - const contentState = editorState.getCurrentContent(); - const blocks = contentState.getBlockMap(); - let newContentState = contentState; - - blocks.forEach((block) => { - const plainText = block.getText(); - - const addEntityToEmoji = (start, end) => { - const existingEntityKey = block.getEntityAt(start); - if (existingEntityKey) { - // avoid manipulation in case the emoji already has an entity - const entity = newContentState.getEntity(existingEntityKey); - if (entity && entity.get('type') === 'emoji') { - return; - } - } - - const selection = SelectionState.createEmpty(block.getKey()) - .set('anchorOffset', start) - .set('focusOffset', end); - const emojiText = plainText.substring(start, end); - newContentState = newContentState.createEntity( - 'emoji', 'IMMUTABLE', { emojiUnicode: emojiText }, - ); - const entityKey = newContentState.getLastCreatedEntityKey(); - newContentState = Modifier.replaceText( - newContentState, - selection, - emojiText, - null, - entityKey, - ); - }; - - findWithRegex(EMOJI_REGEX, block, addEntityToEmoji); - }); - - if (!newContentState.equals(contentState)) { - const oldSelection = editorState.getSelection(); - editorState = EditorState.push( - editorState, - newContentState, - 'convert-to-immutable-emojis', - ); - // this is somewhat of a hack, we're undoing selection changes caused above - // it would be better not to make those changes in the first place - editorState = EditorState.forceSelection(editorState, oldSelection); - } - - return editorState; -} - -export function hasMultiLineSelection(editorState: EditorState): boolean { - const selectionState = editorState.getSelection(); - const anchorKey = selectionState.getAnchorKey(); - const currentContent = editorState.getCurrentContent(); - const currentContentBlock = currentContent.getBlockForKey(anchorKey); - const start = selectionState.getStartOffset(); - const end = selectionState.getEndOffset(); - const selectedText = currentContentBlock.getText().slice(start, end); - return selectedText.includes('\n'); } diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 0bcc08eb06..3a9088e65f 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -191,14 +191,11 @@ function _showAnyInviteErrors(addrs, room) { function _getDirectMessageRooms(addr) { const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); const dmRooms = dmRoomMap.getDMRoomsForUserId(addr); - const rooms = []; - dmRooms.forEach((dmRoom) => { + const rooms = dmRooms.filter((dmRoom) => { const room = MatrixClientPeg.get().getRoom(dmRoom); if (room) { const me = room.getMember(MatrixClientPeg.get().credentials.userId); - if (me.membership == 'join') { - rooms.push(room); - } + return me && me.membership == 'join'; } }); return rooms; diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 9457e6ccfb..3325044b84 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -231,11 +232,12 @@ Example: } */ -const SdkConfig = require('./SdkConfig'); -const MatrixClientPeg = require("./MatrixClientPeg"); -const MatrixEvent = require("matrix-js-sdk").MatrixEvent; -const dis = require("./dispatcher"); -const Widgets = require('./utils/widgets'); +import SdkConfig from './SdkConfig'; +import MatrixClientPeg from './MatrixClientPeg'; +import { MatrixEvent } from 'matrix-js-sdk'; +import dis from './dispatcher'; +import WidgetUtils from './utils/WidgetUtils'; +import RoomViewStore from './stores/RoomViewStore'; import { _t } from './languageHandler'; function sendResponse(event, res) { @@ -286,51 +288,6 @@ function inviteUser(event, roomId, userId) { }); } -/** - * Returns a promise that resolves when a widget with the given - * ID has been added as a user widget (ie. the accountData event - * arrives) or rejects after a timeout - * - * @param {string} widgetId The ID of the widget to wait for - * @param {boolean} add True to wait for the widget to be added, - * false to wait for it to be deleted. - * @returns {Promise} that resolves when the widget is available - */ -function waitForUserWidget(widgetId, add) { - return new Promise((resolve, reject) => { - const currentAccountDataEvent = MatrixClientPeg.get().getAccountData('m.widgets'); - - // Tests an account data event, returning true if it's in the state - // we're waiting for it to be in - function eventInIntendedState(ev) { - if (!ev || !currentAccountDataEvent.getContent()) return false; - if (add) { - return ev.getContent()[widgetId] !== undefined; - } else { - return ev.getContent()[widgetId] === undefined; - } - } - - if (eventInIntendedState(currentAccountDataEvent)) { - resolve(); - return; - } - - function onAccountData(ev) { - if (eventInIntendedState(currentAccountDataEvent)) { - MatrixClientPeg.get().removeListener('accountData', onAccountData); - clearTimeout(timerId); - resolve(); - } - } - const timerId = setTimeout(() => { - MatrixClientPeg.get().removeListener('accountData', onAccountData); - reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear")); - }, 10000); - MatrixClientPeg.get().on('accountData', onAccountData); - }); -} - function setWidget(event, roomId) { const widgetId = event.data.widget_id; const widgetType = event.data.type; @@ -339,12 +296,6 @@ function setWidget(event, roomId) { const widgetData = event.data.data; // optional const userWidget = event.data.userWidget; - const client = MatrixClientPeg.get(); - if (!client) { - sendError(event, _t('You need to be logged in.')); - return; - } - // both adding/removing widgets need these checks if (!widgetId || widgetUrl === undefined) { sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields.")); @@ -371,42 +322,8 @@ function setWidget(event, roomId) { } } - let content = { - type: widgetType, - url: widgetUrl, - name: widgetName, - data: widgetData, - }; - if (userWidget) { - const client = MatrixClientPeg.get(); - const userWidgets = Widgets.getUserWidgets(); - - // Delete existing widget with ID - try { - delete userWidgets[widgetId]; - } catch (e) { - console.error(`$widgetId is non-configurable`); - } - - // Add new widget / update - if (widgetUrl !== null) { - userWidgets[widgetId] = { - content: content, - sender: client.getUserId(), - state_key: widgetId, - type: 'm.widget', - id: widgetId, - }; - } - - // This starts listening for when the echo comes back from the server - // since the widget won't appear added until this happens. If we don't - // wait for this, the action will complete but if the user is fast enough, - // the widget still won't actually be there. - client.setAccountData('m.widgets', userWidgets).then(() => { - return waitForUserWidget(widgetId, widgetUrl !== null); - }).then(() => { + WidgetUtils.setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => { sendResponse(event, { success: true, }); @@ -419,15 +336,7 @@ function setWidget(event, roomId) { if (!roomId) { sendError(event, _t('Missing roomId.'), null); } - - if (widgetUrl === null) { // widget is being deleted - content = {}; - } - // TODO - Room widgets need to be moved to 'm.widget' state events - // https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing - client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { - // XXX: We should probably wait for the echo of the state event to come back from the server, - // as we do with user widgets. + WidgetUtils.setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => { sendResponse(event, { success: true, }); @@ -451,21 +360,13 @@ function getWidgets(event, roomId) { sendError(event, _t('This room is not recognised.')); return; } - // TODO - Room widgets need to be moved to 'm.widget' state events - // https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing - const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); - // Only return widgets which have required fields - if (room) { - stateEvents.forEach((ev) => { - if (ev.getContent().type && ev.getContent().url) { - widgetStateEvents.push(ev.event); // return the raw event - } - }); - } + // XXX: This gets the raw event object (I think because we can't + // send the MatrixEvent over postMessage?) + widgetStateEvents = WidgetUtils.getRoomWidgets(room).map((ev) => ev.event); } // Add user widgets (not linked to a specific room) - const userWidgets = Widgets.getUserWidgetsArray(); + const userWidgets = WidgetUtils.getUserWidgetsArray(); widgetStateEvents = widgetStateEvents.concat(userWidgets); sendResponse(event, widgetStateEvents); @@ -637,19 +538,6 @@ function returnStateEvent(event, roomId, eventType, stateKey) { sendResponse(event, stateEvent.getContent()); } -let currentRoomId = null; -let currentRoomAlias = null; - -// Listen for when a room is viewed -dis.register(onAction); -function onAction(payload) { - if (payload.action !== "view_room") { - return; - } - currentRoomId = payload.room_id; - currentRoomAlias = payload.room_alias; -} - const onMessage = function(event) { if (!event.origin) { // stupid chrome event.origin = event.originalEvent.origin; @@ -700,80 +588,63 @@ const onMessage = function(event) { return; } } - let promise = Promise.resolve(currentRoomId); - if (!currentRoomId) { - if (!currentRoomAlias) { - sendError(event, _t('Must be viewing a room')); - return; - } - // no room ID but there is an alias, look it up. - console.log("Looking up alias " + currentRoomAlias); - promise = MatrixClientPeg.get().getRoomIdForAlias(currentRoomAlias).then((res) => { - return res.room_id; - }); + + if (roomId !== RoomViewStore.getRoomId()) { + sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId})); + return; } - promise.then((viewingRoomId) => { - if (roomId !== viewingRoomId) { - sendError(event, _t('Room %(roomId)s not visible', {roomId: roomId})); - return; - } + // Get and set room-based widgets + if (event.data.action === "get_widgets") { + getWidgets(event, roomId); + return; + } else if (event.data.action === "set_widget") { + setWidget(event, roomId); + return; + } - // Get and set room-based widgets - if (event.data.action === "get_widgets") { - getWidgets(event, roomId); - return; - } else if (event.data.action === "set_widget") { - setWidget(event, roomId); - return; - } + // These APIs don't require userId + if (event.data.action === "join_rules_state") { + getJoinRules(event, roomId); + return; + } else if (event.data.action === "set_plumbing_state") { + setPlumbingState(event, roomId, event.data.status); + return; + } else if (event.data.action === "get_membership_count") { + getMembershipCount(event, roomId); + return; + } else if (event.data.action === "get_room_enc_state") { + getRoomEncState(event, roomId); + return; + } else if (event.data.action === "can_send_event") { + canSendEvent(event, roomId); + return; + } - // These APIs don't require userId - if (event.data.action === "join_rules_state") { - getJoinRules(event, roomId); - return; - } else if (event.data.action === "set_plumbing_state") { - setPlumbingState(event, roomId, event.data.status); - return; - } else if (event.data.action === "get_membership_count") { - getMembershipCount(event, roomId); - return; - } else if (event.data.action === "get_room_enc_state") { - getRoomEncState(event, roomId); - return; - } else if (event.data.action === "can_send_event") { - canSendEvent(event, roomId); - return; - } - - if (!userId) { - sendError(event, _t('Missing user_id in request')); - return; - } - switch (event.data.action) { - case "membership_state": - getMembershipState(event, roomId, userId); - break; - case "invite": - inviteUser(event, roomId, userId); - break; - case "bot_options": - botOptions(event, roomId, userId); - break; - case "set_bot_options": - setBotOptions(event, roomId, userId); - break; - case "set_bot_power": - setBotPower(event, roomId, userId, event.data.level); - break; - default: - console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); - break; - } - }, (err) => { - console.error(err); - sendError(event, _t('Failed to lookup current room') + '.'); - }); + if (!userId) { + sendError(event, _t('Missing user_id in request')); + return; + } + switch (event.data.action) { + case "membership_state": + getMembershipState(event, roomId, userId); + break; + case "invite": + inviteUser(event, roomId, userId); + break; + case "bot_options": + botOptions(event, roomId, userId); + break; + case "set_bot_options": + setBotOptions(event, roomId, userId); + break; + case "set_bot_power": + setBotPower(event, roomId, userId, event.data.level); + break; + default: + console.warn("Unhandled postMessage event with action '" + event.data.action +"'"); + break; + } }; let listenerCount = 0; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index d45e45e84c..9c9573ae21 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -14,28 +14,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixClientPeg from "./MatrixClientPeg"; -import dis from "./dispatcher"; -import Tinter from "./Tinter"; + +import React from 'react'; +import MatrixClientPeg from './MatrixClientPeg'; +import dis from './dispatcher'; +import Tinter from './Tinter'; import sdk from './index'; -import { _t } from './languageHandler'; +import {_t, _td} from './languageHandler'; import Modal from './Modal'; -import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; +import SettingsStore, {SettingLevel} from './settings/SettingsStore'; class Command { - constructor(name, paramArgs, runFn) { - this.name = name; - this.paramArgs = paramArgs; + constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) { + this.command = '/' + name; + this.args = args; + this.description = description; this.runFn = runFn; + this.hideCompletionAfterSpace = hideCompletionAfterSpace; } getCommand() { - return "/" + this.name; + return this.command; } getCommandWithArgs() { - return this.getCommand() + " " + this.paramArgs; + return this.getCommand() + " " + this.args; } run(roomId, args) { @@ -47,16 +51,12 @@ class Command { } } -function reject(msg) { - return { - error: msg, - }; +function reject(error) { + return {error}; } function success(promise) { - return { - promise: promise, - }; + return {promise}; } /* Disable the "unexpected this" error for these commands - all of the run @@ -65,352 +65,410 @@ function success(promise) { /* eslint-disable babel/no-invalid-this */ -const commands = { - ddg: new Command("ddg", "", function(roomId, args) { - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); - // TODO Don't explain this away, actually show a search UI here. - Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { - title: _t('/ddg is not a command'), - description: _t('To use it, just wait for autocomplete results to load and tab through them.'), - }); - return success(); +export const CommandMap = { + ddg: new Command({ + name: 'ddg', + args: '', + description: _td('Searches DuckDuckGo for results'), + runFn: function(roomId, args) { + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + // TODO Don't explain this away, actually show a search UI here. + Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { + title: _t('/ddg is not a command'), + description: _t('To use it, just wait for autocomplete results to load and tab through them.'), + }); + return success(); + }, + hideCompletionAfterSpace: true, }), - // Change your nickname - nick: new Command("nick", "", function(roomId, args) { - if (args) { - return success( - MatrixClientPeg.get().setDisplayName(args), - ); - } - return reject(this.getUsage()); - }), - - // Changes the colorscheme of your current room - tint: new Command("tint", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); - if (matches) { - Tinter.tint(matches[1], matches[4]); - const colorScheme = {}; - colorScheme.primary_color = matches[1]; - if (matches[4]) { - colorScheme.secondary_color = matches[4]; - } else { - colorScheme.secondary_color = colorScheme.primary_color; - } - return success( - SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), - ); + nick: new Command({ + name: 'nick', + args: '', + description: _td('Changes your display nickname'), + runFn: function(roomId, args) { + if (args) { + return success(MatrixClientPeg.get().setDisplayName(args)); } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - // Change the room topic - topic: new Command("topic", "", function(roomId, args) { - if (args) { - return success( - MatrixClientPeg.get().setRoomTopic(roomId, args), - ); - } - return reject(this.getUsage()); - }), - - // Invite a user - invite: new Command("invite", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - return success( - MatrixClientPeg.get().invite(roomId, matches[1]), - ); - } - } - return reject(this.getUsage()); - }), - - // Join a room - join: new Command("join", "#alias:domain", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - let roomAlias = matches[1]; - if (roomAlias[0] !== '#') { - return reject(this.getUsage()); - } - if (!roomAlias.match(/:/)) { - roomAlias += ':' + MatrixClientPeg.get().getDomain(); - } - - dis.dispatch({ - action: 'view_room', - room_alias: roomAlias, - auto_join: true, - }); - - return success(); - } - } - return reject(this.getUsage()); - }), - - part: new Command("part", "[#alias:domain]", function(roomId, args) { - let targetRoomId; - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - let roomAlias = matches[1]; - if (roomAlias[0] !== '#') { - return reject(this.getUsage()); - } - if (!roomAlias.match(/:/)) { - roomAlias += ':' + MatrixClientPeg.get().getDomain(); - } - - // Try to find a room with this alias - const rooms = MatrixClientPeg.get().getRooms(); - for (let i = 0; i < rooms.length; i++) { - const aliasEvents = rooms[i].currentState.getStateEvents( - "m.room.aliases", - ); - for (let j = 0; j < aliasEvents.length; j++) { - const aliases = aliasEvents[j].getContent().aliases || []; - for (let k = 0; k < aliases.length; k++) { - if (aliases[k] === roomAlias) { - targetRoomId = rooms[i].roomId; - break; - } - } - if (targetRoomId) { break; } + tint: new Command({ + name: 'tint', + args: ' []', + description: _td('Changes colour scheme of current room'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(#([\da-fA-F]{3}|[\da-fA-F]{6}))( +(#([\da-fA-F]{3}|[\da-fA-F]{6})))?$/); + if (matches) { + Tinter.tint(matches[1], matches[4]); + const colorScheme = {}; + colorScheme.primary_color = matches[1]; + if (matches[4]) { + colorScheme.secondary_color = matches[4]; + } else { + colorScheme.secondary_color = colorScheme.primary_color; } - if (targetRoomId) { break; } - } - if (!targetRoomId) { - return reject(_t("Unrecognised room alias:") + ' ' + roomAlias); + return success( + SettingsStore.setValue('roomColor', roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), + ); } } - } - if (!targetRoomId) targetRoomId = roomId; - return success( - MatrixClientPeg.get().leave(targetRoomId).then( - function() { - dis.dispatch({action: 'view_next_room'}); - }, - ), - ); + return reject(this.getUsage()); + }, }), - // Kick a user from the room with an optional reason - kick: new Command("kick", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+?)( +(.*))?$/); - if (matches) { - return success( - MatrixClientPeg.get().kick(roomId, matches[1], matches[3]), - ); + topic: new Command({ + name: 'topic', + args: '', + description: _td('Sets the room topic'), + runFn: function(roomId, args) { + if (args) { + return success(MatrixClientPeg.get().setRoomTopic(roomId, args)); } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, + }), + + invite: new Command({ + name: 'invite', + args: '', + description: _td('Invites user with given id to current room'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + return success(MatrixClientPeg.get().invite(roomId, matches[1])); + } + } + return reject(this.getUsage()); + }, + }), + + join: new Command({ + name: 'join', + args: '', + description: _td('Joins room with given alias'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') return reject(this.getUsage()); + + if (!roomAlias.includes(':')) { + roomAlias += ':' + MatrixClientPeg.get().getDomain(); + } + + dis.dispatch({ + action: 'view_room', + room_alias: roomAlias, + auto_join: true, + }); + + return success(); + } + } + return reject(this.getUsage()); + }, + }), + + part: new Command({ + name: 'part', + args: '[]', + description: _td('Leave room'), + runFn: function(roomId, args) { + const cli = MatrixClientPeg.get(); + + let targetRoomId; + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + let roomAlias = matches[1]; + if (roomAlias[0] !== '#') return reject(this.getUsage()); + + if (!roomAlias.includes(':')) { + roomAlias += ':' + cli.getDomain(); + } + + // Try to find a room with this alias + const rooms = cli.getRooms(); + for (let i = 0; i < rooms.length; i++) { + const aliasEvents = rooms[i].currentState.getStateEvents('m.room.aliases'); + for (let j = 0; j < aliasEvents.length; j++) { + const aliases = aliasEvents[j].getContent().aliases || []; + for (let k = 0; k < aliases.length; k++) { + if (aliases[k] === roomAlias) { + targetRoomId = rooms[i].roomId; + break; + } + } + if (targetRoomId) break; + } + if (targetRoomId) break; + } + if (!targetRoomId) return reject(_t('Unrecognised room alias:') + ' ' + roomAlias); + } + } + + if (!targetRoomId) targetRoomId = roomId; + return success( + cli.leave(targetRoomId).then(function() { + dis.dispatch({action: 'view_next_room'}); + }), + ); + }, + }), + + kick: new Command({ + name: 'kick', + args: ' [reason]', + description: _td('Kicks user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + return success(MatrixClientPeg.get().kick(roomId, matches[1], matches[3])); + } + } + return reject(this.getUsage()); + }, }), // Ban a user from the room with an optional reason - ban: new Command("ban", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+?)( +(.*))?$/); - if (matches) { - return success( - MatrixClientPeg.get().ban(roomId, matches[1], matches[3]), - ); + ban: new Command({ + name: 'ban', + args: ' [reason]', + description: _td('Bans user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + return success(MatrixClientPeg.get().ban(roomId, matches[1], matches[3])); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - // Unban a user from the room - unban: new Command("unban", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - // Reset the user membership to "leave" to unban him - return success( - MatrixClientPeg.get().unban(roomId, matches[1]), - ); + // Unban a user from ythe room + unban: new Command({ + name: 'unban', + args: '', + description: _td('Unbans user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + // Reset the user membership to "leave" to unban him + return success(MatrixClientPeg.get().unban(roomId, matches[1])); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - ignore: new Command("ignore", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - const userId = matches[1]; - const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); - ignoredUsers.push(userId); // de-duped internally in the js-sdk - return success( - MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { - title: _t("Ignored user"), - description: ( -

-

{ _t("You are now ignoring %(userId)s", {userId: userId}) }

-
- ), - hasCancelButton: false, - }); - }), - ); + ignore: new Command({ + name: 'ignore', + args: '', + description: _td('Ignores a user, hiding their messages from you'), + runFn: function(roomId, args) { + if (args) { + const cli = MatrixClientPeg.get(); + + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = cli.getIgnoredUsers(); + ignoredUsers.push(userId); // de-duped internally in the js-sdk + return success( + cli.setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { + title: _t('Ignored user'), + description:
+

{ _t('You are now ignoring %(userId)s', {userId}) }

+
, + hasCancelButton: false, + }); + }), + ); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - unignore: new Command("unignore", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - const userId = matches[1]; - const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); - const index = ignoredUsers.indexOf(userId); - if (index !== -1) ignoredUsers.splice(index, 1); - return success( - MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { - title: _t("Unignored user"), - description: ( -
-

{ _t("You are no longer ignoring %(userId)s", {userId: userId}) }

-
- ), - hasCancelButton: false, - }); - }), - ); + unignore: new Command({ + name: 'unignore', + args: '', + description: _td('Stops ignoring a user, showing their messages going forward'), + runFn: function(roomId, args) { + if (args) { + const cli = MatrixClientPeg.get(); + + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = cli.getIgnoredUsers(); + const index = ignoredUsers.indexOf(userId); + if (index !== -1) ignoredUsers.splice(index, 1); + return success( + cli.setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { + title: _t('Unignored user'), + description:
+

{ _t('You are no longer ignoring %(userId)s', {userId}) }

+
, + hasCancelButton: false, + }); + }), + ); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), // Define the power level of a user - op: new Command("op", " []", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+?)( +(-?\d+))?$/); - let powerLevel = 50; // default power level for op - if (matches) { - const userId = matches[1]; - if (matches.length === 4 && undefined !== matches[3]) { - powerLevel = parseInt(matches[3]); - } - if (!isNaN(powerLevel)) { - const room = MatrixClientPeg.get().getRoom(roomId); - if (!room) { - return reject("Bad room ID: " + roomId); + op: new Command({ + name: 'op', + args: ' []', + description: _td('Define the power level of a user'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+?)( +(-?\d+))?$/); + let powerLevel = 50; // default power level for op + if (matches) { + const userId = matches[1]; + if (matches.length === 4 && undefined !== matches[3]) { + powerLevel = parseInt(matches[3]); + } + if (!isNaN(powerLevel)) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + if (!room) return reject('Bad room ID: ' + roomId); + + const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', ''); + return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent)); } - const powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "", - ); - return success( - MatrixClientPeg.get().setPowerLevel( - roomId, userId, powerLevel, powerLevelEvent, - ), - ); } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), // Reset the power level of a user - deop: new Command("deop", "", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+)$/); - if (matches) { - const room = MatrixClientPeg.get().getRoom(roomId); - if (!room) { - return reject("Bad room ID: " + roomId); - } + deop: new Command({ + name: 'deop', + args: '', + description: _td('Deops user with given id'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + if (!room) return reject('Bad room ID: ' + roomId); - const powerLevelEvent = room.currentState.getStateEvents( - "m.room.power_levels", "", - ); - return success( - MatrixClientPeg.get().setPowerLevel( - roomId, args, undefined, powerLevelEvent, - ), - ); + const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', ''); + return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent)); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, }), - // Open developer tools - devtools: new Command("devtools", "", function(roomId) { - const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog"); - Modal.createDialog(DevtoolsDialog, { roomId }); - return success(); + devtools: new Command({ + name: 'devtools', + description: _td('Opens the Developer Tools dialog'), + runFn: function(roomId) { + const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog'); + Modal.createDialog(DevtoolsDialog, {roomId}); + return success(); + }, }), // Verify a user, device, and pubkey tuple - verify: new Command("verify", " ", function(roomId, args) { - if (args) { - const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); - if (matches) { - const userId = matches[1]; - const deviceId = matches[2]; - const fingerprint = matches[3]; + verify: new Command({ + name: 'verify', + args: ' ', + description: _td('Verifies a user, device, and pubkey tuple'), + runFn: function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+) +(\S+) +(\S+)$/); + if (matches) { + const cli = MatrixClientPeg.get(); - return success( - // Promise.resolve to handle transition from static result to promise; can be removed - // in future - Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => { - if (!device) { - throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`); - } + const userId = matches[1]; + const deviceId = matches[2]; + const fingerprint = matches[3]; - if (device.isVerified()) { - if (device.getFingerprint() === fingerprint) { - throw new Error(_t(`Device already verified!`)); - } else { - throw new Error(_t(`WARNING: Device already verified, but keys do NOT MATCH!`)); + return success( + // Promise.resolve to handle transition from static result to promise; can be removed + // in future + Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => { + if (!device) { + throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`); } - } - if (device.getFingerprint() !== fingerprint) { - const fprint = device.getFingerprint(); - throw new Error( - _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' + - ' %(deviceId)s is "%(fprint)s" which does not match the provided key' + - ' "%(fingerprint)s". This could mean your communications are being intercepted!', - {deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint})); - } + if (device.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new Error(_t('Device already verified!')); + } else { + throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!')); + } + } - return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true); - }).then(() => { - // Tell the user we verified everything - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, { - title: _t("Verified key"), - description: ( -
-

- { - _t("The signing key you provided matches the signing key you received " + - "from %(userId)s's device %(deviceId)s. Device marked as verified.", - {userId: userId, deviceId: deviceId}) - } -

-
- ), - hasCancelButton: false, - }); - }), - ); + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new Error( + _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' + + ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' + + '"%(fingerprint)s". This could mean your communications are being intercepted!', + { + fprint, + userId, + deviceId, + fingerprint, + })); + } + + return cli.setDeviceVerified(userId, deviceId, true); + }).then(() => { + // Tell the user we verified everything + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, { + title: _t('Verified key'), + description:
+

+ { + _t('The signing key you provided matches the signing key you received ' + + 'from %(userId)s\'s device %(deviceId)s. Device marked as verified.', + {userId, deviceId}) + } +

+
, + hasCancelButton: false, + }); + }), + ); + } } - } - return reject(this.getUsage()); + return reject(this.getUsage()); + }, + }), + + // Command definitions for autocompletion ONLY: + + // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes + me: new Command({ + name: 'me', + args: '', + description: _td('Displays action'), + hideCompletionAfterSpace: true, }), }; /* eslint-enable babel/no-invalid-this */ @@ -421,50 +479,40 @@ const aliases = { j: "join", }; -module.exports = { - /** - * Process the given text for /commands and perform them. - * @param {string} roomId The room in which the command was performed. - * @param {string} input The raw text input by the user. - * @return {Object|null} An object with the property 'error' if there was an error - * processing the command, or 'promise' if a request was sent out. - * Returns null if the input didn't match a command. - */ - processInput: function(roomId, input) { - // trim any trailing whitespace, as it can confuse the parser for - // IRC-style commands - input = input.replace(/\s+$/, ""); - if (input[0] === "/" && input[1] !== "/") { - const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); - let cmd; - let args; - if (bits) { - cmd = bits[1].substring(1).toLowerCase(); - args = bits[3]; - } else { - cmd = input; - } - if (cmd === "me") return null; - if (aliases[cmd]) { - cmd = aliases[cmd]; - } - if (commands[cmd]) { - return commands[cmd].run(roomId, args); - } else { - return reject(_t("Unrecognised command:") + ' ' + input); - } - } - return null; // not a command - }, - getCommandList: function() { - // Return all the commands plus /me and /markdown which aren't handled like normal commands - const cmds = Object.keys(commands).sort().map(function(cmdKey) { - return commands[cmdKey]; - }); - cmds.push(new Command("me", "", function() {})); - cmds.push(new Command("markdown", "", function() {})); +/** + * Process the given text for /commands and perform them. + * @param {string} roomId The room in which the command was performed. + * @param {string} input The raw text input by the user. + * @return {Object|null} An object with the property 'error' if there was an error + * processing the command, or 'promise' if a request was sent out. + * Returns null if the input didn't match a command. + */ +export function processCommandInput(roomId, input) { + // trim any trailing whitespace, as it can confuse the parser for + // IRC-style commands + input = input.replace(/\s+$/, ''); + if (input[0] !== '/') return null; // not a command - return cmds; - }, -}; + const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); + let cmd; + let args; + if (bits) { + cmd = bits[1].substring(1).toLowerCase(); + args = bits[3]; + } else { + cmd = input; + } + + if (aliases[cmd]) { + cmd = aliases[cmd]; + } + if (CommandMap[cmd]) { + // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me` + if (!CommandMap[cmd].runFn) return null; + + return CommandMap[cmd].run(roomId, args); + } else { + return reject(_t('Unrecognised command:') + ' ' + input); + } +} diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 712150af4d..15c67526d9 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -129,6 +129,64 @@ function textForRoomNameEvent(ev) { }); } +function textForServerACLEvent(ev) { + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const prevContent = ev.getPrevContent(); + const changes = []; + const current = ev.getContent(); + const prev = { + deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], + allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], + allow_ip_literals: !(prevContent.allow_ip_literals === false), + }; + let text = ""; + if (prev.deny.length === 0 && prev.allow.length === 0) { + text = `${senderDisplayName} set server ACLs for this room: `; + } else { + text = `${senderDisplayName} changed the server ACLs for this room: `; + } + + if (!Array.isArray(current.allow)) { + current.allow = []; + } + /* If we know for sure everyone is banned, don't bother showing the diff view */ + if (current.allow.length === 0) { + return text + "🎉 All servers are banned from participating! This room can no longer be used."; + } + + if (!Array.isArray(current.deny)) { + current.deny = []; + } + + const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv)); + const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv)); + const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv)); + const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv)); + + if (bannedServers.length > 0) { + changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`); + } + + if (unbannedServers.length > 0) { + changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`); + } + + if (allowedServers.length > 0) { + changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`); + } + + if (unallowedServers.length > 0) { + changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`); + } + + if (prev.allow_ip_literals !== current.allow_ip_literals) { + const allowban = current.allow_ip_literals ? "allowed" : "banned"; + changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`); + } + + return text + changes.join(" "); +} + function textForMessageEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); let message = senderDisplayName + ': ' + ev.getContent().body; @@ -309,6 +367,7 @@ const stateHandlers = { 'm.room.encryption': textForEncryptionEvent, 'm.room.power_levels': textForPowerEvent, 'm.room.pinned_events': textForPinnedEvent, + 'm.room.server_acl': textForServerACLEvent, 'im.vector.modular.widgets': textForWidgetEvent, }; diff --git a/src/WidgetUtils.js b/src/WidgetUtils.js deleted file mode 100644 index 5f45a8c58c..0000000000 --- a/src/WidgetUtils.js +++ /dev/null @@ -1,58 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import MatrixClientPeg from './MatrixClientPeg'; - -export default class WidgetUtils { - /* Returns true if user is able to send state events to modify widgets in this room - * (Does not apply to non-room-based / user widgets) - * @param roomId -- The ID of the room to check - * @return Boolean -- true if the user can modify widgets in this room - * @throws Error -- specifies the error reason - */ - static canUserModifyWidgets(roomId) { - if (!roomId) { - console.warn('No room ID specified'); - return false; - } - - const client = MatrixClientPeg.get(); - if (!client) { - console.warn('User must be be logged in'); - return false; - } - - const room = client.getRoom(roomId); - if (!room) { - console.warn(`Room ID ${roomId} is not recognised`); - return false; - } - - const me = client.credentials.userId; - if (!me) { - console.warn('Failed to get user ID'); - return false; - } - - const member = room.getMember(me); - if (!member || member.membership !== "join") { - console.warn(`User ${me} is not in room ${roomId}`); - return false; - } - - return room.currentState.maySendStateEvent('im.vector.modular.widgets', me); - } -} diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index c93ae4fb2a..f9fb61d3a3 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,7 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,13 +20,19 @@ import React from 'react'; import type {Completion, SelectionRange} from './Autocompleter'; export default class AutocompleteProvider { - constructor(commandRegex?: RegExp) { + constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) { if (commandRegex) { if (!commandRegex.global) { throw new Error('commandRegex must have global flag set'); } this.commandRegex = commandRegex; } + if (forcedCommandRegex) { + if (!forcedCommandRegex.global) { + throw new Error('forcedCommandRegex must have global flag set'); + } + this.forcedCommandRegex = forcedCommandRegex; + } } destroy() { @@ -36,11 +42,11 @@ export default class AutocompleteProvider { /** * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. */ - getCurrentCommand(query: string, selection: {start: number, end: number}, force: boolean = false): ?string { + getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false): ?string { let commandRegex = this.commandRegex; if (force && this.shouldForceComplete()) { - commandRegex = /\S+/g; + commandRegex = this.forcedCommandRegex || /\S+/g; } if (commandRegex == null) { @@ -51,14 +57,14 @@ export default class AutocompleteProvider { let match; while ((match = commandRegex.exec(query)) != null) { - let matchStart = match.index, - matchEnd = matchStart + match[0].length; - if (selection.start <= matchEnd && selection.end >= matchStart) { + const start = match.index; + const end = start + match[0].length; + if (selection.start <= end && selection.end >= start) { return { command: match, range: { - start: matchStart, - end: matchEnd, + start, + end, }, }; } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 3d30363d9f..7f91676cc3 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -1,6 +1,6 @@ /* Copyright 2016 Aviral Dasgupta -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,7 +18,9 @@ limitations under the License. // @flow import type {Component} from 'react'; +import {Room} from 'matrix-js-sdk'; import CommandProvider from './CommandProvider'; +import CommunityProvider from './CommunityProvider'; import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; @@ -27,8 +29,9 @@ import NotifProvider from './NotifProvider'; import Promise from 'bluebird'; export type SelectionRange = { - start: number, - end: number + beginning: boolean, // whether the selection is in the first block of the editor or not + start: number, // byte offset relative to the start anchor of the current editor selection. + end: number, // byte offset relative to the end anchor of the current editor selection. }; export type Completion = { @@ -47,6 +50,7 @@ const PROVIDERS = [ EmojiProvider, NotifProvider, CommandProvider, + CommunityProvider, DuckDuckGoProvider, ]; @@ -54,7 +58,7 @@ const PROVIDERS = [ const PROVIDER_COMPLETION_TIMEOUT = 3000; export default class Autocompleter { - constructor(room) { + constructor(room: Room) { this.room = room; this.providers = PROVIDERS.map((p) => { return new p(room); @@ -77,12 +81,12 @@ export default class Autocompleter { // Array of inspections of promises that might timeout. Instead of allowing a // single timeout to reject the Promise.all, reflect each one and once they've all // settled, filter for the fulfilled ones - this.providers.map((provider) => { - return provider + this.providers.map(provider => + provider .getCompletions(query, selection, force) .timeout(PROVIDER_COMPLETION_TIMEOUT) - .reflect(); - }), + .reflect() + ), ); return completionsList.filter( diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index e33fa7861f..a35a31966a 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -2,6 +2,7 @@ Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd Copyright 2017 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,103 +18,16 @@ limitations under the License. */ import React from 'react'; -import { _t, _td } from '../languageHandler'; +import {_t} from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; +import type {Completion, SelectionRange} from "./Autocompleter"; +import {CommandMap} from '../SlashCommands'; -// TODO merge this with the factory mechanics of SlashCommands? -// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file -const COMMANDS = [ - { - command: '/me', - args: '', - description: _td('Displays action'), - }, - { - command: '/ban', - args: ' [reason]', - description: _td('Bans user with given id'), - }, - { - command: '/unban', - args: '', - description: _td('Unbans user with given id'), - }, - { - command: '/op', - args: ' []', - description: _td('Define the power level of a user'), - }, - { - command: '/deop', - args: '', - description: _td('Deops user with given id'), - }, - { - command: '/invite', - args: '', - description: _td('Invites user with given id to current room'), - }, - { - command: '/join', - args: '', - description: _td('Joins room with given alias'), - }, - { - command: '/part', - args: '[]', - description: _td('Leave room'), - }, - { - command: '/topic', - args: '', - description: _td('Sets the room topic'), - }, - { - command: '/kick', - args: ' [reason]', - description: _td('Kicks user with given id'), - }, - { - command: '/nick', - args: '', - description: _td('Changes your display nickname'), - }, - { - command: '/ddg', - args: '', - description: _td('Searches DuckDuckGo for results'), - }, - { - command: '/tint', - args: ' []', - description: _td('Changes colour scheme of current room'), - }, - { - command: '/verify', - args: ' ', - description: _td('Verifies a user, device, and pubkey tuple'), - }, - { - command: '/ignore', - args: '', - description: _td('Ignores a user, hiding their messages from you'), - }, - { - command: '/unignore', - args: '', - description: _td('Stops ignoring a user, showing their messages going forward'), - }, - { - command: '/devtools', - args: '', - description: _td('Opens the Developer Tools dialog'), - }, - // Omitting `/markdown` as it only seems to apply to OldComposer -]; +const COMMANDS = Object.values(CommandMap); -const COMMAND_RE = /(^\/\w*)/g; +const COMMAND_RE = /(^\/\w*)(?: .*)?/g; export default class CommandProvider extends AutocompleteProvider { constructor() { @@ -123,23 +37,39 @@ export default class CommandProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: {start: number, end: number}) { - let completions = []; + async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array { const {command, range} = this.getCurrentCommand(query, selection); - if (command) { - completions = this.matcher.match(command[0]).map((result) => { - return { - completion: result.command + ' ', - component: (), - range, - }; - }); + if (!command) return []; + + let matches = []; + // check if the full match differs from the first word (i.e. returns false if the command has args) + if (command[0] !== command[1]) { + // The input looks like a command with arguments, perform exact match + const name = command[1].substr(1); // strip leading `/` + if (CommandMap[name]) { + // some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments + if (CommandMap[name].hideCompletionAfterSpace) return []; + matches = [CommandMap[name]]; + } + } else { + if (query === '/') { + // If they have just entered `/` show everything + matches = COMMANDS; + } else { + // otherwise fuzzy match against all of the fields + matches = this.matcher.match(command[1]); + } } - return completions; + + return matches.map((result) => ({ + // If the command is the same as the one they entered, we don't want to discard their arguments + completion: result.command === command[1] ? command[0] : (result.command + ' '), + component: , + range, + })); } getName() { diff --git a/src/autocomplete/CommunityProvider.js b/src/autocomplete/CommunityProvider.js new file mode 100644 index 0000000000..6bcf1a02fd --- /dev/null +++ b/src/autocomplete/CommunityProvider.js @@ -0,0 +1,111 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import { _t } from '../languageHandler'; +import AutocompleteProvider from './AutocompleteProvider'; +import MatrixClientPeg from '../MatrixClientPeg'; +import FuzzyMatcher from './FuzzyMatcher'; +import {PillCompletion} from './Components'; +import sdk from '../index'; +import _sortBy from 'lodash/sortBy'; +import {makeGroupPermalink} from "../matrix-to"; +import type {Completion, SelectionRange} from "./Autocompleter"; +import FlairStore from "../stores/FlairStore"; + +const COMMUNITY_REGEX = /\B\+\S*/g; + +function score(query, space) { + const index = space.indexOf(query); + if (index === -1) { + return Infinity; + } else { + return index; + } +} + +export default class CommunityProvider extends AutocompleteProvider { + constructor() { + super(COMMUNITY_REGEX); + this.matcher = new FuzzyMatcher([], { + keys: ['groupId', 'name', 'shortDescription'], + }); + } + + async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array { + const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); + + // Disable autocompletions when composing commands because of various issues + // (see https://github.com/vector-im/riot-web/issues/4762) + if (/^(\/join|\/leave)/.test(query)) { + return []; + } + + const cli = MatrixClientPeg.get(); + let completions = []; + const {command, range} = this.getCurrentCommand(query, selection, force); + if (command) { + const joinedGroups = cli.getGroups().filter(({myMembership}) => myMembership === 'join'); + + const groups = (await Promise.all(joinedGroups.map(async ({groupId}) => { + try { + return FlairStore.getGroupProfileCached(cli, groupId); + } catch (e) { // if FlairStore failed, fall back to just groupId + return Promise.resolve({ + name: '', + groupId, + avatarUrl: '', + shortDescription: '', + }); + } + }))); + + this.matcher.setObjects(groups); + + const matchedString = command[0]; + completions = this.matcher.match(matchedString); + completions = _sortBy(completions, [ + (c) => score(matchedString, c.groupId), + (c) => c.groupId.length, + ]).map(({avatarUrl, groupId, name}) => ({ + completion: groupId, + suffix: ' ', + href: makeGroupPermalink(groupId), + component: ( + + } title={name} description={groupId} /> + ), + range, + })) + .slice(0, 4); + } + return completions; + } + + getName() { + return '💬 ' + _t('Communities'); + } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ { completions } +
; + } +} diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index 68d4915f56..e25ef16428 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -1,7 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import AutocompleteProvider from './AutocompleteProvider'; import 'whatwg-fetch'; import {TextualCompletion} from './Components'; +import type {SelectionRange} from "./Autocompleter"; const DDG_REGEX = /\/ddg\s+(.+)$/g; const REFERRER = 'vector'; @@ -36,7 +37,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; } - async getCompletions(query: string, selection: {start: number, end: number}) { + async getCompletions(query: string, selection: SelectionRange, force?: boolean = false) { const {command, range} = this.getCurrentCommand(query, selection); if (!query || !command) { return []; diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index f4e576ea0f..719550d59f 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -1,7 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,11 +19,11 @@ limitations under the License. import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; -import {emojioneList, shortnameToImage, shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione'; +import {shortnameToUnicode, asciiRegexp, unicodeRegexp} from 'emojione'; import FuzzyMatcher from './FuzzyMatcher'; import sdk from '../index'; import {PillCompletion} from './Components'; -import type {SelectionRange, Completion} from './Autocompleter'; +import type {Completion, SelectionRange} from './Autocompleter'; import _uniq from 'lodash/uniq'; import _sortBy from 'lodash/sortBy'; import SettingsStore from "../settings/SettingsStore"; @@ -48,7 +48,7 @@ const CATEGORY_ORDER = [ // (^|\s|(emojiUnicode)) to make sure we're either at the start of the string or there's a // whitespace character or an emoji before the emoji. The reason for unicodeRegexp is // that we need to support inputting multiple emoji with no space between them. -const EMOJI_REGEX = new RegExp('(?:^|\\s|' + unicodeRegexp + ')(' + asciiRegexp + '|:\\w*:?)$', 'g'); +const EMOJI_REGEX = new RegExp('(?:^|\\s|' + unicodeRegexp + ')(' + asciiRegexp + '|:[+-\\w]*:?)$', 'g'); // We also need to match the non-zero-length prefixes to remove them from the final match, // and update the range so that we don't replace the whitespace or the previous emoji. @@ -65,6 +65,7 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor return { name: a.name, shortname: a.shortname, + aliases: a.aliases ? a.aliases.join(' ') : '', aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '', // Include the index so that we can preserve the original order _orderBy: index, @@ -84,7 +85,7 @@ export default class EmojiProvider extends AutocompleteProvider { constructor() { super(EMOJI_REGEX); this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, { - keys: ['aliases_ascii', 'shortname'], + keys: ['aliases_ascii', 'shortname', 'aliases'], // For matching against ascii equivalents shouldMatchWordsOnly: false, }); @@ -95,7 +96,7 @@ export default class EmojiProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: SelectionRange) { + async getCompletions(query: string, selection: SelectionRange, force?: boolean): Array { if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) { return []; // don't give any suggestions if the user doesn't want them } diff --git a/src/autocomplete/NotifProvider.js b/src/autocomplete/NotifProvider.js index b7ac645525..432388c255 100644 --- a/src/autocomplete/NotifProvider.js +++ b/src/autocomplete/NotifProvider.js @@ -20,6 +20,7 @@ import { _t } from '../languageHandler'; import MatrixClientPeg from '../MatrixClientPeg'; import {PillCompletion} from './Components'; import sdk from '../index'; +import type {Completion, SelectionRange} from "./Autocompleter"; const AT_ROOM_REGEX = /@\S*/g; @@ -29,7 +30,7 @@ export default class NotifProvider extends AutocompleteProvider { this.room = room; } - async getCompletions(query: string, selection: {start: number, end: number}, force = false) { + async getCompletions(query: string, selection: SelectionRange, force?:boolean = false): Array { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const client = MatrixClientPeg.get(); @@ -40,6 +41,7 @@ export default class NotifProvider extends AutocompleteProvider { if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) { return [{ completion: '@room', + completionId: '@room', suffix: ' ', component: ( } title="@room" description={_t("Notify the whole room")} /> diff --git a/src/autocomplete/PlainWithPillsSerializer.js b/src/autocomplete/PlainWithPillsSerializer.js new file mode 100644 index 0000000000..59cf1bde3b --- /dev/null +++ b/src/autocomplete/PlainWithPillsSerializer.js @@ -0,0 +1,93 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Based originally on slate-plain-serializer + +import { Block } from 'slate'; + +/** + * Plain text serializer, which converts a Slate `value` to a plain text string, + * serializing pills into various different formats as required. + * + * @type {PlainWithPillsSerializer} + */ + +class PlainWithPillsSerializer { + + /* + * @param {String} options.pillFormat - either 'md', 'plain', 'id' + */ + constructor(options = {}) { + const { + pillFormat = 'plain', + } = options; + this.pillFormat = pillFormat; + } + + /** + * Serialize a Slate `value` to a plain text string, + * serializing pills as either MD links, plain text representations or + * ID representations as required. + * + * @param {Value} value + * @return {String} + */ + serialize = value => { + return this._serializeNode(value.document); + } + + /** + * Serialize a `node` to plain text. + * + * @param {Node} node + * @return {String} + */ + _serializeNode = node => { + if ( + node.object == 'document' || + (node.object == 'block' && Block.isBlockList(node.nodes)) + ) { + return node.nodes.map(this._serializeNode).join('\n'); + } else if (node.type == 'emoji') { + return node.data.get('emojiUnicode'); + } else if (node.type == 'pill') { + const completion = node.data.get('completion'); + // over the wire the @room pill is just plaintext + if (completion === '@room') return completion; + + switch (this.pillFormat) { + case 'plain': + return completion; + case 'md': + return `[${ completion }](${ node.data.get('href') })`; + case 'id': + return node.data.get('completionId') || completion; + } + } else if (node.nodes) { + return node.nodes.map(this._serializeNode).join(''); + } else { + return node.text; + } + } +} + +/** + * Export. + * + * @type {PlainWithPillsSerializer} + */ + +export default PlainWithPillsSerializer; diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js index 762b285685..9d4d4d0598 100644 --- a/src/autocomplete/QueryMatcher.js +++ b/src/autocomplete/QueryMatcher.js @@ -1,6 +1,7 @@ //@flow /* Copyright 2017 Aviral Dasgupta +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -27,6 +28,10 @@ class KeyMap { priorityMap = new Map(); } +function stripDiacritics(str: string): string { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); +} + export default class QueryMatcher { /** * @param {object[]} objects the objects to perform a match on @@ -46,10 +51,11 @@ export default class QueryMatcher { objects.forEach((object, i) => { const keyValues = _at(object, keys); for (const keyValue of keyValues) { - if (!map.hasOwnProperty(keyValue)) { - map[keyValue] = []; + const key = stripDiacritics(keyValue).toLowerCase(); + if (!map.hasOwnProperty(key)) { + map[key] = []; } - map[keyValue].push(object); + map[key].push(object); } keyMap.priorityMap.set(object, i); }); @@ -82,7 +88,7 @@ export default class QueryMatcher { } match(query: String): Array { - query = query.toLowerCase(); + query = stripDiacritics(query).toLowerCase(); if (this.options.shouldMatchWordsOnly) { query = query.replace(/[^\w]/g, ''); } @@ -91,7 +97,7 @@ export default class QueryMatcher { } const results = []; this.keyMap.keys.forEach((key) => { - let resultKey = key.toLowerCase(); + let resultKey = key; if (this.options.shouldMatchWordsOnly) { resultKey = resultKey.replace(/[^\w]/g, ''); } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 31599703c2..38e2ab8373 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,7 +1,8 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -26,8 +27,9 @@ import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; import _sortBy from 'lodash/sortBy'; import {makeRoomPermalink} from "../matrix-to"; +import type {Completion, SelectionRange} from "./Autocompleter"; -const ROOM_REGEX = /(?=#)(\S*)/g; +const ROOM_REGEX = /\B#\S*/g; function score(query, space) { const index = space.indexOf(query); @@ -46,15 +48,9 @@ export default class RoomProvider extends AutocompleteProvider { }); } - async getCompletions(query: string, selection: {start: number, end: number}, force = false) { + async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); - // Disable autocompletions when composing commands because of various issues - // (see https://github.com/vector-im/riot-web/issues/4762) - if (/^(\/join|\/leave)/.test(query)) { - return []; - } - const client = MatrixClientPeg.get(); let completions = []; const {command, range} = this.getCurrentCommand(query, selection, force); @@ -78,6 +74,7 @@ export default class RoomProvider extends AutocompleteProvider { const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; return { completion: displayAlias, + completionId: displayAlias, suffix: ' ', href: makeRoomPermalink(displayAlias), component: ( diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index ce8f1020a1..156aac2eb8 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -2,7 +2,8 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd +Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -23,28 +24,30 @@ import AutocompleteProvider from './AutocompleteProvider'; import {PillCompletion} from './Components'; import sdk from '../index'; import FuzzyMatcher from './FuzzyMatcher'; -import _pull from 'lodash/pull'; import _sortBy from 'lodash/sortBy'; import MatrixClientPeg from '../MatrixClientPeg'; -import type {Room, RoomMember} from 'matrix-js-sdk'; +import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk'; import {makeUserPermalink} from "../matrix-to"; +import type {Completion, SelectionRange} from "./Autocompleter"; -const USER_REGEX = /@\S*/g; +const USER_REGEX = /\B@\S*/g; + +// used when you hit 'tab' - we allow some separator chars at the beginning +// to allow you to tab-complete /mat into /(matthew) +const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g; export default class UserProvider extends AutocompleteProvider { users: Array = null; room: Room = null; constructor(room) { - super(USER_REGEX, { - keys: ['name'], - }); + super(USER_REGEX, FORCED_USER_REGEX); this.room = room; this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], shouldMatchPrefix: true, - shouldMatchWordsOnly: false + shouldMatchWordsOnly: false, }); this._onRoomTimelineBound = this._onRoomTimeline.bind(this); @@ -61,7 +64,7 @@ export default class UserProvider extends AutocompleteProvider { } } - _onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + _onRoomTimeline(ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean, data: Object) { if (!room) return; if (removed) return; if (room.roomId !== this.room.roomId) return; @@ -77,7 +80,7 @@ export default class UserProvider extends AutocompleteProvider { this.onUserSpoke(ev.sender); } - _onRoomStateMember(ev, state, member) { + _onRoomStateMember(ev: MatrixEvent, state: RoomState, member: RoomMember) { // ignore members in other rooms if (member.roomId !== this.room.roomId) { return; @@ -87,15 +90,9 @@ export default class UserProvider extends AutocompleteProvider { this.users = null; } - async getCompletions(query: string, selection: {start: number, end: number}, force = false) { + async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array { const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); - // Disable autocompletions when composing commands because of various issues - // (see https://github.com/vector-im/riot-web/issues/4762) - if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) { - return []; - } - // lazy-load user list into matcher if (this.users === null) this._makeUsers(); @@ -113,7 +110,8 @@ export default class UserProvider extends AutocompleteProvider { // Length of completion should equal length of text in decorator. draft-js // relies on the length of the entity === length of the text in the decoration. completion: user.rawDisplayName.replace(' (IRC)', ''), - suffix: range.start === 0 ? ': ' : ' ', + completionId: user.userId, + suffix: (selection.beginning && range.start === 0) ? ': ' : ' ', href: makeUserPermalink(user.userId), component: ( { - if (member.userId !== currentUserId) return true; - }); + this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId); - this.users = _sortBy(this.users, (member) => - 1E20 - lastSpoken[member.userId] || 1E20, - ); + this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20); this.matcher.setObjects(this.users); } diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index daac294d12..7295fd45d3 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,12 +16,10 @@ limitations under the License. */ -'use strict'; - -const classNames = require('classnames'); -const React = require('react'); -const ReactDOM = require('react-dom'); +import React from 'react'; +import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -61,6 +60,54 @@ export default class ContextualMenu extends React.Component { // If true, insert an invisible screen-sized element behind the // menu that when clicked will close it. hasBackground: PropTypes.bool, + + // The component to render as the context menu + elementClass: PropTypes.element.isRequired, + // on resize callback + windowResize: PropTypes.func, + // method to close menu + closeMenu: PropTypes.func, + }; + + constructor() { + super(); + this.state = { + contextMenuRect: null, + }; + + this.onContextMenu = this.onContextMenu.bind(this); + this.collectContextMenuRect = this.collectContextMenuRect.bind(this); + } + + collectContextMenuRect(element) { + // We don't need to clean up when unmounting, so ignore + if (!element) return; + + this.setState({ + contextMenuRect: element.getBoundingClientRect(), + }); + } + + onContextMenu(e) { + if (this.props.closeMenu) { + this.props.closeMenu(); + + e.preventDefault(); + const x = e.clientX; + const y = e.clientY; + + // XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst + // a context menu and its click-guard are up without completely rewriting how the context menus work. + setImmediate(() => { + const clickEvent = document.createEvent('MouseEvents'); + clickEvent.initMouseEvent( + 'contextmenu', true, true, window, 0, + 0, 0, x, y, false, false, + false, false, 0, null, + ); + document.elementFromPoint(x, y).dispatchEvent(clickEvent); + }); + } } render() { @@ -83,6 +130,9 @@ export default class ContextualMenu extends React.Component { chevronFace = 'right'; } + const contextMenuRect = this.state.contextMenuRect || null; + const padding = 10; + const chevronOffset = {}; if (props.chevronFace) { chevronFace = props.chevronFace; @@ -90,7 +140,19 @@ export default class ContextualMenu extends React.Component { if (chevronFace === 'top' || chevronFace === 'bottom') { chevronOffset.left = props.chevronOffset; } else { - chevronOffset.top = props.chevronOffset; + const target = position.top; + + // By default, no adjustment is made + let adjusted = target; + + // If we know the dimensions of the context menu, adjust its position + // such that it does not leave the (padded) window. + if (contextMenuRect) { + adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding); + } + + position.top = adjusted; + chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted); } // To override the default chevron colour, if it's been set @@ -112,7 +174,7 @@ export default class ContextualMenu extends React.Component { `; } - const chevron =
; + const chevron =
; const className = 'mx_ContextualMenu_wrapper'; const menuClasses = classNames({ @@ -154,17 +216,18 @@ export default class ContextualMenu extends React.Component { // FIXME: If a menu uses getDefaultProps it clobbers the onFinished // property set here so you can't close the menu from a button click! return
-
+
{ chevron }
- { props.hasBackground &&
} + { props.hasBackground &&
}
; } } -export function createMenu(ElementClass, props) { +export function createMenu(ElementClass, props, hasBackground=true) { const closeMenu = function(...args) { ReactDOM.unmountComponentAtNode(getOrCreateContainer()); @@ -175,8 +238,8 @@ export function createMenu(ElementClass, props) { // We only reference closeMenu once per call to createMenu const menu =
{ _t('Only people who have been invited') } @@ -1051,7 +1071,7 @@ export default React.createClass({
{ _t('Everyone') } @@ -1114,10 +1134,6 @@ export default React.createClass({ let avatarNode; let nameNode; let shortDescNode; - const bodyNodes = [ - this._getMembershipSection(), - this._getGroupSection(), - ]; const rightButtons = []; if (this.state.editing && this.state.isUserPrivileged) { let avatarImage; @@ -1194,6 +1210,7 @@ export default React.createClass({ shortDescNode = { summary.profile.short_description }; } } + if (this.state.editing) { rightButtons.push( , ); } + rightButtons.push( + + + , + ); if (this.props.collapsedRhs) { rightButtons.push(
- { bodyNodes } + { this._getMembershipSection() } + { this._getGroupSection() }
); diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 457ed6f068..a181482814 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -82,17 +82,26 @@ var LeftPanel = React.createClass({ _onKeyDown: function(ev) { if (!this.focusedElement) return; - let handled = false; + let handled = true; switch (ev.keyCode) { + case KeyCode.TAB: + this._onMoveFocus(ev.shiftKey); + break; case KeyCode.UP: this._onMoveFocus(true); - handled = true; break; case KeyCode.DOWN: this._onMoveFocus(false); - handled = true; break; + case KeyCode.ENTER: + this._onMoveFocus(false); + if (this.focusedElement) { + this.focusedElement.click(); + } + break; + default: + handled = false; } if (handled) { @@ -102,37 +111,33 @@ var LeftPanel = React.createClass({ }, _onMoveFocus: function(up) { - var element = this.focusedElement; + let element = this.focusedElement; // unclear why this isn't needed // var descending = (up == this.focusDirection) ? this.focusDescending : !this.focusDescending; // this.focusDirection = up; - var descending = false; // are we currently descending or ascending through the DOM tree? - var classes; + let descending = false; // are we currently descending or ascending through the DOM tree? + let classes; do { - var child = up ? element.lastElementChild : element.firstElementChild; - var sibling = up ? element.previousElementSibling : element.nextElementSibling; + const child = up ? element.lastElementChild : element.firstElementChild; + const sibling = up ? element.previousElementSibling : element.nextElementSibling; if (descending) { if (child) { element = child; - } - else if (sibling) { + } else if (sibling) { element = sibling; - } - else { + } else { descending = false; element = element.parentElement; } - } - else { + } else { if (sibling) { element = sibling; descending = true; - } - else { + } else { element = element.parentElement; } } @@ -144,8 +149,7 @@ var LeftPanel = React.createClass({ descending = true; } } - - } while(element && !( + } while (element && !( classes.contains("mx_RoomTile") || classes.contains("mx_SearchBox_search") || classes.contains("mx_RoomSubList_ellipsis"))); diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 2dd5a75c47..5dca359f32 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -255,6 +255,22 @@ const LoggedInView = React.createClass({ ), true); }, + _onClick: function(ev) { + // When the panels are disabled, clicking on them results in a mouse event + // which bubbles to certain elements in the tree. When this happens, close + // any settings page that is currently open (user/room/group). + if (this.props.leftDisabled && + this.props.rightDisabled && + ( + ev.target.className === 'mx_MatrixChat' || + ev.target.className === 'mx_MatrixChat_middlePanel' || + ev.target.className === 'mx_RoomView' + ) + ) { + dis.dispatch({ action: 'close_settings' }); + } + }, + render: function() { const LeftPanel = sdk.getComponent('structures.LeftPanel'); const RightPanel = sdk.getComponent('structures.RightPanel'); @@ -295,7 +311,7 @@ const LoggedInView = React.createClass({ case PageTypes.UserSettings: page_element = +
{ topBar }
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 96e721f7ca..e0bbf50d5a 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -23,6 +23,7 @@ import PropTypes from 'prop-types'; import Matrix from "matrix-js-sdk"; import Analytics from "../../Analytics"; +import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import MatrixClientPeg from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; @@ -398,6 +399,9 @@ export default React.createClass({ }, startPageChangeTimer() { + // Tor doesn't support performance + if (!performance || !performance.mark) return null; + // This shouldn't happen because componentWillUpdate and componentDidUpdate // are used. if (this._pageChanging) { @@ -409,6 +413,9 @@ export default React.createClass({ }, stopPageChangeTimer() { + // Tor doesn't support performance + if (!performance || !performance.mark) return null; + if (!this._pageChanging) { console.warn('MatrixChat.stopPageChangeTimer: timer not started'); return; @@ -560,6 +567,27 @@ export default React.createClass({ this._setPage(PageTypes.UserSettings); this.notifyNewScreen('settings'); break; + case 'close_settings': + this.setState({ + leftDisabled: false, + rightDisabled: false, + middleDisabled: false, + }); + if (this.state.page_type === PageTypes.UserSettings) { + // We do this to get setPage and notifyNewScreen + if (this.state.currentRoomId) { + this._viewRoom({ + room_id: this.state.currentRoomId, + }); + } else if (this.state.currentGroupId) { + this._viewGroup({ + group_id: this.state.currentGroupId, + }); + } else { + this._viewHome(); + } + } + break; case 'view_create_room': this._createRoom(); break; @@ -577,19 +605,10 @@ export default React.createClass({ this.notifyNewScreen('groups'); break; case 'view_group': - { - const groupId = payload.group_id; - this.setState({ - currentGroupId: groupId, - currentGroupIsNew: payload.group_is_new, - }); - this._setPage(PageTypes.GroupView); - this.notifyNewScreen('group/' + groupId); - } + this._viewGroup(payload); break; case 'view_home_page': - this._setPage(PageTypes.HomePage); - this.notifyNewScreen('home'); + this._viewHome(); break; case 'view_set_mxid': this._setMxId(payload); @@ -632,7 +651,8 @@ export default React.createClass({ middleDisabled: payload.middleDisabled || false, rightDisabled: payload.rightDisabled || payload.sideDisabled || false, }); - break; } + break; + } case 'set_theme': this._onSetTheme(payload.value); break; @@ -781,7 +801,6 @@ export default React.createClass({ // @param {string=} roomInfo.room_id ID of the room to join. One of room_id or room_alias must be given. // @param {string=} roomInfo.room_alias Alias of the room to join. One of room_id or room_alias must be given. // @param {boolean=} roomInfo.auto_join If true, automatically attempt to join the room if not already a member. - // @param {boolean=} roomInfo.show_settings Makes RoomView show the room settings dialog. // @param {string=} roomInfo.event_id ID of the event in this room to show: this will cause a switch to the // context of that particular event. // @param {boolean=} roomInfo.highlighted If true, add event_id to the hash of the URL @@ -848,6 +867,21 @@ export default React.createClass({ }); }, + _viewGroup: function(payload) { + const groupId = payload.group_id; + this.setState({ + currentGroupId: groupId, + currentGroupIsNew: payload.group_is_new, + }); + this._setPage(PageTypes.GroupView); + this.notifyNewScreen('group/' + groupId); + }, + + _viewHome: function() { + this._setPage(PageTypes.HomePage); + this.notifyNewScreen('home'); + }, + _setMxId: function(payload) { const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { @@ -996,10 +1030,20 @@ export default React.createClass({ }, (err) => { modal.close(); console.error("Failed to leave room " + roomId + " " + err); + let title = _t("Failed to leave room"); + let message = _t("Server may be unavailable, overloaded, or you hit a bug."); + if (err.errcode == 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') { + title = _t("Can't leave Server Notices room"); + message = _t( + "This room is used for important messages from the Homeserver, " + + "so you cannot leave it.", + ); + } else if (err && err.message) { + message = err.message; + } Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { - title: _t("Failed to leave room"), - description: (err && err.message ? err.message : - _t("Server may be unavailable, overloaded, or you hit a bug.")), + title: title, + description: message, }); }); } @@ -1100,11 +1144,6 @@ export default React.createClass({ } else if (this._is_registered) { this._is_registered = false; - // Set the display name = user ID localpart - MatrixClientPeg.get().setDisplayName( - MatrixClientPeg.get().getUserIdLocalpart(), - ); - if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { createRoom({ dmUserId: this.props.config.welcomeUserId, @@ -1265,6 +1304,32 @@ export default React.createClass({ } }); + const dft = new DecryptionFailureTracker((total, errorCode) => { + Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total); + }, (errorCode) => { + // Map JS-SDK error codes to tracker codes for aggregation + switch (errorCode) { + case 'MEGOLM_UNKNOWN_INBOUND_SESSION_ID': + return 'olm_keys_not_sent_error'; + case 'OLM_UNKNOWN_MESSAGE_INDEX': + return 'olm_index_error'; + case undefined: + return 'unexpected_error'; + default: + return 'unspecified_error'; + } + }); + + // Shelved for later date when we have time to think about persisting history of + // tracked events across sessions. + // dft.loadTrackedEventHashMap(); + + dft.start(); + + // When logging out, stop tracking failures and destroy state + cli.on("Session.logged_out", () => dft.stop()); + cli.on("Event.decrypted", (e, err) => dft.eventDecrypted(e, err)); + const krh = new KeyRequestHandler(cli); cli.on("crypto.roomKeyRequest", (req) => { krh.handleKeyRequest(req); @@ -1596,19 +1661,8 @@ export default React.createClass({ this._setPageSubtitle(subtitle); }, - onUserSettingsClose: function() { - // XXX: use browser history instead to find the previous room? - // or maintain a this.state.pageHistory in _setPage()? - if (this.state.currentRoomId) { - dis.dispatch({ - action: 'view_room', - room_id: this.state.currentRoomId, - }); - } else { - dis.dispatch({ - action: 'view_home_page', - }); - } + onCloseAllSettings() { + dis.dispatch({ action: 'close_settings' }); }, onServerConfigChange(config) { @@ -1667,7 +1721,7 @@ export default React.createClass({ return ( ; } diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 7a93cfb886..edb50fcedb 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -70,7 +70,7 @@ export default withMatrixClient(React.createClass({ if (this.state.groups) { const groupNodes = []; this.state.groups.forEach((g) => { - groupNodes.push(); + groupNodes.push(); }); contentHeader = groupNodes.length > 0 ?

{ _t('Your Communities') }

:
; content = groupNodes.length > 0 ? @@ -124,7 +124,7 @@ export default withMatrixClient(React.createClass({ ) }
-
+ {/*
@@ -140,7 +140,7 @@ export default withMatrixClient(React.createClass({ { 'i': (sub) => { sub } }) }
-
+
*/}
{ contentHeader } diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 8034923158..9aa77e695a 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -25,6 +25,7 @@ import MatrixClientPeg from '../../MatrixClientPeg'; import MemberAvatar from '../views/avatars/MemberAvatar'; import Resend from '../../Resend'; import * as cryptodevices from '../../cryptodevices'; +import dis from '../../dispatcher'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -157,10 +158,12 @@ module.exports = React.createClass({ _onResendAllClick: function() { Resend.resendUnsentEvents(this.props.room); + dis.dispatch({action: 'focus_composer'}); }, _onCancelAllClick: function() { Resend.cancelUnsentEvents(this.props.room); + dis.dispatch({action: 'focus_composer'}); }, _onShowDevicesClick: function() { @@ -305,7 +308,26 @@ module.exports = React.createClass({ }, ); } else { - if ( + let consentError = null; + for (const m of unsentMessages) { + if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') { + consentError = m.error; + break; + } + } + if (consentError) { + title = _t( + "You can't send any messages until you review and agree to " + + "our terms and conditions.", + {}, + { + 'consentLink': (sub) => + + { sub } + , + }, + ); + } else if ( unsentMessages.length === 1 && unsentMessages[0].error && unsentMessages[0].error.data && @@ -329,11 +351,13 @@ module.exports = React.createClass({ return
{_t("Warning")} -
- { title } -
-
- { content } +
+
+ { title } +
+
+ { content } +
; }, @@ -350,11 +374,13 @@ module.exports = React.createClass({ return (
/!\ -
- { _t('Connectivity to the server has been lost.') } -
-
- { _t('Sent messages will be stored until your connection has returned.') } +
+
+ { _t('Connectivity to the server has been lost.') } +
+
+ { _t('Sent messages will be stored until your connection has returned.') } +
); diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index bf64986680..b07f12f995 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -1,6 +1,7 @@ /* -Copyright 2017 Vector Creations Ltd Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,56 +16,53 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -var React = require('react'); -var ReactDOM = require('react-dom'); -var classNames = require('classnames'); -var sdk = require('../../index'); +import React from 'react'; +import classNames from 'classnames'; +import sdk from '../../index'; import { Droppable } from 'react-beautiful-dnd'; import { _t } from '../../languageHandler'; -var dis = require('../../dispatcher'); -var Unread = require('../../Unread'); -var MatrixClientPeg = require('../../MatrixClientPeg'); -var RoomNotifs = require('../../RoomNotifs'); -var FormattingUtils = require('../../utils/FormattingUtils'); -var AccessibleButton = require('../../components/views/elements/AccessibleButton'); -import Modal from '../../Modal'; +import dis from '../../dispatcher'; +import Unread from '../../Unread'; +import * as RoomNotifs from '../../RoomNotifs'; +import * as FormattingUtils from '../../utils/FormattingUtils'; import { KeyCode } from '../../Keyboard'; +import { Group } from 'matrix-js-sdk'; +import PropTypes from 'prop-types'; // turn this on for drop & drag console debugging galore -var debug = false; +const debug = false; const TRUNCATE_AT = 10; -var RoomSubList = React.createClass({ +const RoomSubList = React.createClass({ displayName: 'RoomSubList', debug: debug, propTypes: { - list: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, - label: React.PropTypes.string.isRequired, - tagName: React.PropTypes.string, - editable: React.PropTypes.bool, + list: PropTypes.arrayOf(PropTypes.object).isRequired, + label: PropTypes.string.isRequired, + tagName: PropTypes.string, + editable: PropTypes.bool, - order: React.PropTypes.string.isRequired, + order: PropTypes.string.isRequired, // passed through to RoomTile and used to highlight room with `!` regardless of notifications count - isInvite: React.PropTypes.bool, + isInvite: PropTypes.bool, - startAsHidden: React.PropTypes.bool, - showSpinner: React.PropTypes.bool, // true to show a spinner if 0 elements when expanded - collapsed: React.PropTypes.bool.isRequired, // is LeftPanel collapsed? - onHeaderClick: React.PropTypes.func, - alwaysShowHeader: React.PropTypes.bool, - incomingCall: React.PropTypes.object, - onShowMoreRooms: React.PropTypes.func, - searchFilter: React.PropTypes.string, - emptyContent: React.PropTypes.node, // content shown if the list is empty - headerItems: React.PropTypes.node, // content shown in the sublist header - extraTiles: React.PropTypes.arrayOf(React.PropTypes.node), // extra elements added beneath tiles + startAsHidden: PropTypes.bool, + showSpinner: PropTypes.bool, // true to show a spinner if 0 elements when expanded + collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed? + onHeaderClick: PropTypes.func, + alwaysShowHeader: PropTypes.bool, + incomingCall: PropTypes.object, + onShowMoreRooms: PropTypes.func, + searchFilter: PropTypes.string, + emptyContent: PropTypes.node, // content shown if the list is empty + headerItems: PropTypes.node, // content shown in the sublist header + extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles + showEmpty: PropTypes.bool, }, getInitialState: function() { @@ -77,10 +75,13 @@ var RoomSubList = React.createClass({ getDefaultProps: function() { return { - onHeaderClick: function() {}, // NOP - onShowMoreRooms: function() {}, // NOP + onHeaderClick: function() { + }, // NOP + onShowMoreRooms: function() { + }, // NOP extraTiles: [], isInvite: false, + showEmpty: true, }; }, @@ -105,15 +106,17 @@ var RoomSubList = React.createClass({ applySearchFilter: function(list, filter) { if (filter === "") return list; - return list.filter((room) => { - return room.name && room.name.toLowerCase().indexOf(filter.toLowerCase()) >= 0 - }); + const lcFilter = filter.toLowerCase(); + // case insensitive if room name includes filter, + // or if starts with `#` and one of room's aliases starts with filter + return list.filter((room) => (room.name && room.name.toLowerCase().includes(lcFilter)) || + (filter[0] === '#' && room.getAliases().some((alias) => alias.toLowerCase().startsWith(lcFilter)))); }, // The header is collapsable if it is hidden or not stuck // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method isCollapsableOnClick: function() { - var stuck = this.refs.header.dataset.stuck; + const stuck = this.refs.header.dataset.stuck; if (this.state.hidden || stuck === undefined || stuck === "none") { return true; } else { @@ -139,12 +142,12 @@ var RoomSubList = React.createClass({ onClick: function(ev) { if (this.isCollapsableOnClick()) { // The header isCollapsable, so the click is to be interpreted as collapse and truncation logic - var isHidden = !this.state.hidden; - this.setState({ hidden : isHidden }); + const isHidden = !this.state.hidden; + this.setState({hidden: isHidden}); if (isHidden) { // as good a way as any to reset the truncate state - this.setState({ truncateAt : TRUNCATE_AT }); + this.setState({truncateAt: TRUNCATE_AT}); } this.props.onShowMoreRooms(); @@ -159,7 +162,7 @@ var RoomSubList = React.createClass({ dis.dispatch({ action: 'view_room', room_id: roomId, - clear_search: (ev && (ev.keyCode == KeyCode.ENTER || ev.keyCode == KeyCode.SPACE)), + clear_search: (ev && (ev.keyCode === KeyCode.ENTER || ev.keyCode === KeyCode.SPACE)), }); }, @@ -169,17 +172,17 @@ var RoomSubList = React.createClass({ }, _shouldShowMentionBadge: function(roomNotifState) { - return roomNotifState != RoomNotifs.MUTE; + return roomNotifState !== RoomNotifs.MUTE; }, /** * Total up all the notification counts from the rooms * - * @param {Number} If supplied will only total notifications for rooms outside the truncation number + * @param {Number} truncateAt If supplied will only total notifications for rooms outside the truncation number * @returns {Array} The array takes the form [total, highlight] where highlight is a bool */ roomNotificationCount: function(truncateAt) { - var self = this; + const self = this; if (this.props.isInvite) { return [0, true]; @@ -187,9 +190,9 @@ var RoomSubList = React.createClass({ return this.props.list.reduce(function(result, room, index) { if (truncateAt === undefined || index >= truncateAt) { - var roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId); - var highlight = room.getUnreadNotificationCount('highlight') > 0; - var notificationCount = room.getUnreadNotificationCount(); + const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId); + const highlight = room.getUnreadNotificationCount('highlight') > 0; + const notificationCount = room.getUnreadNotificationCount(); const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState); const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState); @@ -238,38 +241,83 @@ var RoomSubList = React.createClass({ }); }, + _onNotifBadgeClick: function(e) { + // prevent the roomsublist collapsing + e.preventDefault(); + e.stopPropagation(); + // find first room which has notifications and switch to it + for (const room of this.state.sortedList) { + const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId); + const highlight = room.getUnreadNotificationCount('highlight') > 0; + const notificationCount = room.getUnreadNotificationCount(); + + const notifBadges = notificationCount > 0 && this._shouldShowNotifBadge(roomNotifState); + const mentionBadges = highlight && this._shouldShowMentionBadge(roomNotifState); + + if (notifBadges || mentionBadges) { + dis.dispatch({ + action: 'view_room', + room_id: room.roomId, + }); + return; + } + } + }, + + _onInviteBadgeClick: function(e) { + // prevent the roomsublist collapsing + e.preventDefault(); + e.stopPropagation(); + // switch to first room in sortedList as that'll be the top of the list for the user + if (this.state.sortedList && this.state.sortedList.length > 0) { + dis.dispatch({ + action: 'view_room', + room_id: this.state.sortedList[0].roomId, + }); + } else if (this.props.extraTiles && this.props.extraTiles.length > 0) { + // Group Invites are different in that they are all extra tiles and not rooms + // XXX: this is a horrible special case because Group Invite sublist is a hack + if (this.props.extraTiles[0].props && this.props.extraTiles[0].props.group instanceof Group) { + dis.dispatch({ + action: 'view_group', + group_id: this.props.extraTiles[0].props.group.groupId, + }); + } + } + }, + _getHeaderJsx: function() { - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + const subListNotifications = this.roomNotificationCount(); + const subListNotifCount = subListNotifications[0]; + const subListNotifHighlight = subListNotifications[1]; - var subListNotifications = this.roomNotificationCount(); - var subListNotifCount = subListNotifications[0]; - var subListNotifHighlight = subListNotifications[1]; + const totalTiles = this.props.list.length + (this.props.extraTiles || []).length; + const roomCount = totalTiles > 0 ? totalTiles : ''; - var totalTiles = this.props.list.length + (this.props.extraTiles || []).length; - var roomCount = totalTiles > 0 ? totalTiles : ''; - - var chevronClasses = classNames({ + const chevronClasses = classNames({ 'mx_RoomSubList_chevron': true, 'mx_RoomSubList_chevronRight': this.state.hidden, 'mx_RoomSubList_chevronDown': !this.state.hidden, }); - var badgeClasses = classNames({ + const badgeClasses = classNames({ 'mx_RoomSubList_badge': true, 'mx_RoomSubList_badgeHighlight': subListNotifHighlight, }); - var badge; + let badge; if (subListNotifCount > 0) { - badge =
{ FormattingUtils.formatCount(subListNotifCount) }
; + badge =
+ { FormattingUtils.formatCount(subListNotifCount) } +
; } else if (this.props.isInvite) { // no notifications but highlight anyway because this is an invite badge - badge =
!
; + badge =
!
; } // When collapsed, allow a long hover on the header to show user // the full tag name and room count - var title; + let title; if (this.props.collapsed) { title = this.props.label; if (roomCount !== '') { @@ -277,22 +325,24 @@ var RoomSubList = React.createClass({ } } - var incomingCall; + let incomingCall; if (this.props.incomingCall) { - var self = this; + const self = this; // Check if the incoming call is for this section - var incomingCallRoom = this.props.list.filter(function(room) { + const incomingCallRoom = this.props.list.filter(function(room) { return self.props.incomingCall.roomId === room.roomId; }); if (incomingCallRoom.length === 1) { - var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox"); - incomingCall = ; + const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox"); + incomingCall = + ; } } - var tabindex = this.props.searchFilter === "" ? "0" : "-1"; + const tabindex = this.props.searchFilter === "" ? "0" : "-1"; + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return (
@@ -307,33 +357,34 @@ var RoomSubList = React.createClass({ }, _createOverflowTile: function(overflowCount, totalCount) { - var content =
; + let content =
; - var overflowNotifications = this.roomNotificationCount(TRUNCATE_AT); - var overflowNotifCount = overflowNotifications[0]; - var overflowNotifHighlight = overflowNotifications[1]; + const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT); + const overflowNotifCount = overflowNotifications[0]; + const overflowNotifHighlight = overflowNotifications[1]; if (overflowNotifCount && !this.props.collapsed) { content = FormattingUtils.formatCount(overflowNotifCount); } - var badgeClasses = classNames({ + const badgeClasses = classNames({ 'mx_RoomSubList_moreBadge': true, 'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed, 'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed, }); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return ( -
-
{ _t("more") }
-
{ content }
+
+
{_t("more")}
+
{content}
); }, _showFullMemberList: function() { this.setState({ - truncateAt: -1 + truncateAt: -1, }); this.props.onShowMoreRooms(); @@ -341,37 +392,51 @@ var RoomSubList = React.createClass({ }, render: function() { - var connectDropTarget = this.props.connectDropTarget; - var TruncatedList = sdk.getComponent('elements.TruncatedList'); - - var label = this.props.collapsed ? null : this.props.label; + const TruncatedList = sdk.getComponent('elements.TruncatedList'); let content; - if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) { - content = this.props.emptyContent; + + if (this.props.showEmpty) { + // this is new behaviour with still controversial UX in that in hiding RoomSubLists the drop zones for DnD + // are also gone so when filtering users can't DnD rooms to some tags but is a lot cleaner otherwise. + if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) { + content = this.props.emptyContent; + } else { + content = this.makeRoomTiles(); + content.push(...this.props.extraTiles); + } } else { - content = this.makeRoomTiles(); - content.push(...this.props.extraTiles); + if (this.state.sortedList.length === 0 && this.props.extraTiles.length === 0) { + // if no search filter is applied and there is a placeholder defined then show it, otherwise show nothing + if (!this.props.searchFilter && this.props.emptyContent) { + content = this.props.emptyContent; + } else { + // don't show an empty sublist + return null; + } + } else { + content = this.makeRoomTiles(); + content.push(...this.props.extraTiles); + } } if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) { - var subList; - var classes = "mx_RoomSubList"; + let subList; + const classes = "mx_RoomSubList"; if (!this.state.hidden) { - subList = - { content } - ; - } - else { - subList = - ; + subList = + {content} + ; + } else { + subList = + ; } const subListContent =
- { this._getHeaderJsx() } - { subList } + {this._getHeaderJsx()} + {subList}
; return this.props.editable ? @@ -379,23 +444,26 @@ var RoomSubList = React.createClass({ droppableId={"room-sub-list-droppable_" + this.props.tagName} type="draggable-RoomTile" > - { (provided, snapshot) => ( + {(provided, snapshot) => (
- { subListContent } + {subListContent}
- ) } + )} : subListContent; - } - else { - var Loader = sdk.getComponent("elements.Spinner"); + } else { + const Loader = sdk.getComponent("elements.Spinner"); + if (this.props.showSpinner) { + content = ; + } + return (
- { this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined } - { (this.props.showSpinner && !this.state.hidden) ? : undefined } + {this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined} + { this.state.hidden ? undefined : content }
); } - } + }, }); module.exports = RoomSubList; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index c5f6a75cc5..0325b3d9a6 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -44,7 +45,9 @@ import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import RoomViewStore from '../../stores/RoomViewStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; -import SettingsStore from "../../settings/SettingsStore"; +import WidgetEchoStore from '../../stores/WidgetEchoStore'; +import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; +import WidgetUtils from '../../utils/WidgetUtils'; const DEBUG = false; let debuglog = function() {}; @@ -115,6 +118,7 @@ module.exports = React.createClass({ showApps: false, isAlone: false, isPeeking: false, + showingPinned: false, // error object, as from the matrix client/server API // If we failed to load information about the room, @@ -150,6 +154,8 @@ module.exports = React.createClass({ // Start listening for RoomViewStore updates this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._onRoomViewStoreUpdate(true); + + WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); }, _onRoomViewStoreUpdate: function(initial) { @@ -182,6 +188,8 @@ module.exports = React.createClass({ isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), forwardingEvent: RoomViewStore.getForwardingEvent(), shouldPeek: RoomViewStore.shouldPeek(), + showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", RoomViewStore.getRoomId()), + editingRoomSettings: RoomViewStore.isEditingSettings(), }; // Temporary logging to diagnose https://github.com/vector-im/riot-web/issues/4307 @@ -238,6 +246,12 @@ module.exports = React.createClass({ } }, + _onWidgetEchoStoreUpdate: function() { + this.setState({ + showApps: this._shouldShowApps(this.state.room), + }); + }, + _setupRoom: function(room, roomId, joining, shouldPeek) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) @@ -294,11 +308,11 @@ module.exports = React.createClass({ throw err; } }); + } else if (room) { + // Stop peeking because we have joined this room previously + MatrixClientPeg.get().stopPeeking(); + this.setState({isPeeking: false}); } - } else if (room) { - // Stop peeking because we have joined this room previously - MatrixClientPeg.get().stopPeeking(); - this.setState({isPeeking: false}); } }, @@ -314,14 +328,9 @@ module.exports = React.createClass({ return false; } - const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); - // any valid widget = show apps - for (let i = 0; i < appsStateEvents.length; i++) { - if (appsStateEvents[i].getContent().type && appsStateEvents[i].getContent().url) { - return true; - } - } - return false; + const widgets = WidgetEchoStore.getEchoedRoomWidgets(room.roomId, WidgetUtils.getRoomWidgets(room)); + + return widgets.length > 0 || WidgetEchoStore.roomHasPendingWidgets(room.roomId, WidgetUtils.getRoomWidgets(room)); }, componentDidMount: function() { @@ -416,6 +425,8 @@ module.exports = React.createClass({ this._roomStoreToken.remove(); } + WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate); + // cancel any pending calls to the rate_limited_funcs this._updateRoomMembers.cancelPendingCall(); @@ -615,9 +626,11 @@ module.exports = React.createClass({ } }, - _updatePreviewUrlVisibility: function(room) { + _updatePreviewUrlVisibility: function({roomId}) { + // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit + const key = MatrixClientPeg.get().isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; this.setState({ - showUrlPreview: SettingsStore.getValue("urlPreviewsEnabled", room.roomId), + showUrlPreview: SettingsStore.getValue(key, roomId), }); }, @@ -642,19 +655,23 @@ module.exports = React.createClass({ }, onAccountData: function(event) { - if (event.getType() === "org.matrix.preview_urls" && this.state.room) { + const type = event.getType(); + if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { + // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` this._updatePreviewUrlVisibility(this.state.room); } }, onRoomAccountData: function(event, room) { if (room.roomId == this.state.roomId) { - if (event.getType() === "org.matrix.room.color_scheme") { + const type = event.getType(); + if (type === "org.matrix.room.color_scheme") { const color_scheme = event.getContent(); // XXX: we should validate the event console.log("Tinter.tint from onRoomAccountData"); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); - } else if (event.getType() === "org.matrix.room.preview_urls") { + } else if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") { + // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` this._updatePreviewUrlVisibility(room); } } @@ -672,6 +689,7 @@ module.exports = React.createClass({ } this._updateRoomMembers(); + this._checkIfAlone(this.state.room); }, onRoomMemberMembership: function(ev, member, oldMembership) { @@ -909,6 +927,8 @@ module.exports = React.createClass({ }, uploadFile: async function(file) { + dis.dispatch({action: 'focus_composer'}); + if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'view_set_mxid'}); return; @@ -1135,11 +1155,14 @@ module.exports = React.createClass({ }, onPinnedClick: function() { - this.setState({showingPinned: !this.state.showingPinned, searching: false}); + const nowShowingPinned = !this.state.showingPinned; + const roomId = this.state.room.roomId; + this.setState({showingPinned: nowShowingPinned, searching: false}); + SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned); }, onSettingsClick: function() { - this.showSettings(true); + dis.dispatch({ action: 'open_room_settings' }); }, onSettingsSaveClick: function() { @@ -1172,24 +1195,20 @@ module.exports = React.createClass({ }); // still editing room settings } else { - this.setState({ - editingRoomSettings: false, - }); + dis.dispatch({ action: 'close_settings' }); } }).finally(() => { this.setState({ uploadingRoomSettings: false, - editingRoomSettings: false, }); + dis.dispatch({ action: 'close_settings' }); }).done(); }, onCancelClick: function() { console.log("updateTint from onCancelClick"); this.updateTint(); - this.setState({ - editingRoomSettings: false, - }); + dis.dispatch({ action: 'close_settings' }); if (this.state.forwardingEvent) { dis.dispatch({ action: 'forward_event', @@ -1406,13 +1425,6 @@ module.exports = React.createClass({ });*/ }, - showSettings: function(show) { - // XXX: this is a bit naughty; we should be doing this via props - if (show) { - this.setState({editingRoomSettings: true}); - } - }, - /** * called by the parent component when PageUp/Down/etc is pressed. * diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 0b6dc9fc75..652211595b 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd. +Copyright 2017, 2018 New Vector Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -26,6 +26,7 @@ import dis from '../../dispatcher'; import { _t } from '../../languageHandler'; import { Droppable } from 'react-beautiful-dnd'; +import classNames from 'classnames'; const TagPanel = React.createClass({ displayName: 'TagPanel', @@ -84,7 +85,10 @@ const TagPanel = React.createClass({ }, onMouseDown(e) { - dis.dispatch({action: 'deselect_tags'}); + // only dispatch if its not a no-op + if (this.state.selectedTags.length > 0) { + dis.dispatch({action: 'deselect_tags'}); + } }, onCreateGroupClick(ev) { @@ -113,17 +117,26 @@ const TagPanel = React.createClass({ />; }); - const clearButton = this.state.selectedTags.length > 0 ? - : -
; + const itemsSelected = this.state.selectedTags.length > 0; - return
- + let clearButton; + if (itemsSelected) { + clearButton = + + ; + } + + const classes = classNames('mx_TagPanel', { + mx_TagPanel_items_selected: itemsSelected, + }); + + return
+
{ clearButton } - +
track.stop()); + } + Promise.resolve().then(() => { return CallMediaHandler.getDevices(); }).then((mediaDevices) => { @@ -293,6 +300,7 @@ module.exports = React.createClass({ if (this._unmounted) return; this.setState({ mediaDevices, + activeAudioOutput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audiooutput'), activeAudioInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audioinput'), activeVideoInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_videoinput'), }); @@ -423,7 +431,6 @@ module.exports = React.createClass({ "push notifications on other devices until you log back in to them", ) + ".", }); - dis.dispatch({action: 'password_changed'}); }, _onAddEmailEditFinished: function(value, shouldSubmit) { @@ -971,6 +978,11 @@ module.exports = React.createClass({ return devices.map((device) => { device.label }); }, + _setAudioOutput: function(deviceId) { + this.setState({activeAudioOutput: deviceId}); + CallMediaHandler.setAudioOutput(deviceId); + }, + _setAudioInput: function(deviceId) { this.setState({activeAudioInput: deviceId}); CallMediaHandler.setAudioInput(deviceId); @@ -1011,6 +1023,7 @@ module.exports = React.createClass({ const Dropdown = sdk.getComponent('elements.Dropdown'); + let speakerDropdown =

{ _t('No Audio Outputs detected') }

; let microphoneDropdown =

{ _t('No Microphones detected') }

; let webcamDropdown =

{ _t('No Webcams detected') }

; @@ -1019,6 +1032,26 @@ module.exports = React.createClass({ label: _t('Default Device'), }; + const audioOutputs = this.state.mediaDevices.audiooutput.slice(0); + if (audioOutputs.length > 0) { + let defaultOutput = ''; + if (!audioOutputs.some((input) => input.deviceId === 'default')) { + audioOutputs.unshift(defaultOption); + } else { + defaultOutput = 'default'; + } + + speakerDropdown =
+

{ _t('Audio Output') }

+ + { this._mapWebRtcDevicesToSpans(audioOutputs) } + +
; + } + const audioInputs = this.state.mediaDevices.audioinput.slice(0); if (audioInputs.length > 0) { let defaultInput = ''; @@ -1060,8 +1093,9 @@ module.exports = React.createClass({ } return
- { microphoneDropdown } - { webcamDropdown } + { speakerDropdown } + { microphoneDropdown } + { webcamDropdown }
; }, @@ -1075,6 +1109,14 @@ module.exports = React.createClass({
; }, + onSelfShareClick: function() { + const cli = MatrixClientPeg.get(); + const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); + Modal.createTrackedDialog('share self dialog', '', ShareDialog, { + target: cli.getUser(this._me), + }); + }, + _showSpoiler: function(event) { const target = event.target; target.innerHTML = target.getAttribute('data-spoiler'); @@ -1296,10 +1338,13 @@ module.exports = React.createClass({
- { _t("Logged in as:") } { this._me } + { _t("Logged in as:") + ' ' } + + { this._me } +
- { _t('Access Token:') } + { _t('Access Token:') + ' ' } diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index ca50b9db6e..7e0cd5da8e 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,8 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; @@ -45,6 +43,8 @@ module.exports = React.createClass({ enteredHomeserverUrl: this.props.customHsUrl || this.props.defaultHsUrl, enteredIdentityServerUrl: this.props.customIsUrl || this.props.defaultIsUrl, progress: null, + password: null, + password2: null, }; }, @@ -103,7 +103,7 @@ module.exports = React.createClass({
, button: _t('Continue'), extraButtons: [ - , @@ -169,7 +169,8 @@ module.exports = React.createClass({ } else if (this.state.progress === "sent_email") { resetPasswordJsx = (
- { _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", { emailAddress: this.state.email }) } + { _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, " + + "click below.", { emailAddress: this.state.email }) }
@@ -179,14 +180,15 @@ module.exports = React.createClass({ resetPasswordJsx = (

{ _t('Your password has been reset') }.

-

{ _t('You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device') }.

+

{ _t('You have been logged out of all devices and will no longer receive push notifications. ' + + 'To re-enable notifications, sign in again on each device') }.

); } else { let serverConfigSection; - if (!SdkConfig.get().disable_custom_urls) { + if (!SdkConfig.get()['disable_custom_urls']) { serverConfigSection = (
@@ -233,6 +237,7 @@ module.exports = React.createClass({ { _t('Create an account') } +
diff --git a/src/components/structures/login/LanguageSelector.js b/src/components/structures/login/LanguageSelector.js new file mode 100644 index 0000000000..965d8334d9 --- /dev/null +++ b/src/components/structures/login/LanguageSelector.js @@ -0,0 +1,38 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import SdkConfig from "../../../SdkConfig"; +import {getCurrentLanguage} from "../../../languageHandler"; +import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; +import PlatformPeg from "../../../PlatformPeg"; +import sdk from '../../../index'; +import React from 'react'; + +function onChange(newLang) { + if (getCurrentLanguage() !== newLang) { + SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang); + PlatformPeg.get().reload(); + } +} + +export default function LanguageSelector() { + if (SdkConfig.get()['disable_login_language_selector']) return
; + + const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); + return
+ +
; +} diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 7f4aa0325a..43264e7003 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,15 +21,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; -import * as languageHandler from '../../../languageHandler'; import sdk from '../../../index'; import Login from '../../../Login'; -import PlatformPeg from '../../../PlatformPeg'; import SdkConfig from '../../../SdkConfig'; -import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; +import SettingsStore from "../../../settings/SettingsStore"; // For validating phone numbers without country codes -const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/; +const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; /** * A wire component which glues together login UI components and Login logic @@ -94,6 +93,13 @@ module.exports = React.createClass({ this._unmounted = true; }, + onPasswordLoginError: function(errorText) { + this.setState({ + errorText, + loginIncorrect: Boolean(errorText), + }); + }, + onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { this.setState({ busy: true, @@ -113,10 +119,10 @@ module.exports = React.createClass({ // Some error strings only apply for logging in const usingEmail = username.indexOf("@") > 0; - if (error.httpStatus == 400 && usingEmail) { + if (error.httpStatus === 400 && usingEmail) { errorText = _t('This Home Server does not support login using email address.'); } else if (error.httpStatus === 401 || error.httpStatus === 403) { - if (SdkConfig.get().disable_custom_urls) { + if (SdkConfig.get()['disable_custom_urls']) { errorText = (
{ _t('Incorrect username and/or password.') }
@@ -143,7 +149,7 @@ module.exports = React.createClass({ // but the login API gives a 403 https://matrix.org/jira/browse/SYN-744 // mentions this (although the bug is for UI auth which is not this) // We treat both as an incorrect password - loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403, + loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403, }); }).finally(() => { if (this._unmounted) { @@ -231,7 +237,7 @@ module.exports = React.createClass({ hsUrl = hsUrl || this.state.enteredHomeserverUrl; isUrl = isUrl || this.state.enteredIdentityServerUrl; - const fallbackHsUrl = hsUrl == this.props.defaultHsUrl ? this.props.fallbackHsUrl : null; + const fallbackHsUrl = hsUrl === this.props.defaultHsUrl ? this.props.fallbackHsUrl : null; const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, @@ -310,19 +316,27 @@ module.exports = React.createClass({ !this.state.enteredHomeserverUrl.startsWith("http")) ) { errorText = - { - _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + - "Either use HTTPS or enable unsafe scripts.", - {}, - { 'a': (sub) => { return { sub }; } }, + { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + + "Either use HTTPS or enable unsafe scripts.", {}, + { + 'a': (sub) => { + return + { sub } + ; + }, + }, ) } ; } else { errorText = - { - _t("Can't connect to homeserver - please check your connectivity, ensure your homeserver's SSL certificate is trusted, and that a browser extension is not blocking requests.", - {}, - { 'a': (sub) => { return { sub }; } }, + { _t("Can't connect to homeserver - please check your connectivity, ensure your " + + "homeserver's SSL certificate is trusted, and that a browser extension " + + "is not blocking requests.", {}, + { + 'a': (sub) => { + return { sub }; + }, + }, ) } ; } @@ -350,6 +364,7 @@ module.exports = React.createClass({ return ( - -
; - }, - render: function() { const Loader = sdk.getComponent("elements.Spinner"); const LoginPage = sdk.getComponent("login.LoginPage"); @@ -399,25 +397,14 @@ module.exports = React.createClass({ if (this.props.enableGuest) { loginAsGuestJsx = - { _t('Login as guest') } + { _t('Try the app first') } ; } - let returnToAppJsx; - /* - // with the advent of ILAG I don't think we need this any more - if (this.props.onCancelClick) { - returnToAppJsx = - - { _t('Return to app') } - ; - } - */ - let serverConfig; let header; - if (!SdkConfig.get().disable_custom_urls) { + if (!SdkConfig.get()['disable_custom_urls']) { serverConfig =
@@ -460,8 +449,7 @@ module.exports = React.createClass({ { _t('Create an account') } { loginAsGuestJsx } - { returnToAppJsx } - { !SdkConfig.get().disable_login_language_selector ? this._renderLanguageSetting() : '' } +
diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 62a3ee4f68..462063406f 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,7 +23,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import sdk from '../../../index'; -import ServerConfig from '../../views/login/ServerConfig'; import MatrixClientPeg from '../../../MatrixClientPeg'; import RegistrationForm from '../../views/login/RegistrationForm'; import RtsClient from '../../../RtsClient'; @@ -62,6 +62,12 @@ module.exports = React.createClass({ onLoginClick: PropTypes.func.isRequired, onCancelClick: PropTypes.func, onServerConfigChange: PropTypes.func.isRequired, + + rtsClient: PropTypes.shape({ + getTeamsConfig: PropTypes.func.isRequired, + trackReferral: PropTypes.func.isRequired, + getTeam: PropTypes.func.isRequired, + }), }, getInitialState: function() { @@ -133,7 +139,7 @@ module.exports = React.createClass({ newState.isUrl = config.isUrl; } this.props.onServerConfigChange(config); - this.setState(newState, function() { + this.setState(newState, () => { this._replaceClient(); }); }, @@ -159,11 +165,11 @@ module.exports = React.createClass({ let msg = response.message || response.toString(); // can we give a better error message? if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { - let msisdn_available = false; + let msisdnAvailable = false; for (const flow of response.available_flows) { - msisdn_available |= flow.stages.indexOf('m.login.msisdn') > -1; + msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1; } - if (!msisdn_available) { + if (!msisdnAvailable) { msg = _t('This server does not support authentication with a phone number.'); } } @@ -242,7 +248,7 @@ module.exports = React.createClass({ return matrixClient.getPushers().then((resp)=>{ const pushers = resp.pushers; for (let i = 0; i < pushers.length; ++i) { - if (pushers[i].kind == 'email') { + if (pushers[i].kind === 'email') { const emailPusher = pushers[i]; emailPusher.data = { brand: this.props.brand }; matrixClient.setPusher(emailPusher).done(() => { @@ -267,7 +273,7 @@ module.exports = React.createClass({ errMsg = _t('Passwords don\'t match.'); break; case "RegistrationForm.ERR_PASSWORD_LENGTH": - errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH: MIN_PASSWORD_LENGTH}); + errMsg = _t('Password too short (min %(MIN_PASSWORD_LENGTH)s).', {MIN_PASSWORD_LENGTH}); break; case "RegistrationForm.ERR_EMAIL_INVALID": errMsg = _t('This doesn\'t look like a valid email address.'); @@ -353,7 +359,7 @@ module.exports = React.createClass({ registerBody = ; } else { let serverConfigSection; - if (!SdkConfig.get().disable_custom_urls) { + if (!SdkConfig.get()['disable_custom_urls']) { serverConfigSection = ( - { _t('Return to app') } - - ); - } - */ - let header; let errorText; // FIXME: remove hardcoded Status team tweaks at some point @@ -418,6 +412,8 @@ module.exports = React.createClass({ ); } + const LanguageSelector = sdk.getComponent('structures.login.LanguageSelector'); + return (
@@ -431,7 +427,7 @@ module.exports = React.createClass({ { registerBody } { signIn } { errorText } - { returnToAppJsx } +
diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js new file mode 100644 index 0000000000..e30acca16d --- /dev/null +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -0,0 +1,87 @@ +/* +Copyright 2018 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import Modal from '../../../Modal'; +import {Group} from 'matrix-js-sdk'; +import GroupStore from "../../../stores/GroupStore"; + +export default class GroupInviteTileContextMenu extends React.Component { + static propTypes = { + group: PropTypes.instanceOf(Group).isRequired, + /* callback called when the menu is dismissed */ + onFinished: PropTypes.func, + }; + + constructor(props, context) { + super(props, context); + + this._onClickReject = this._onClickReject.bind(this); + } + + componentWillMount() { + this._unmounted = false; + } + + componentWillUnmount() { + this._unmounted = true; + } + + _onClickReject() { + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Reject community invite', '', QuestionDialog, { + title: _t('Reject invitation'), + description: _t('Are you sure you want to reject the invitation?'), + onFinished: async (shouldLeave) => { + if (!shouldLeave) return; + + // FIXME: controller shouldn't be loading a view :( + const Loader = sdk.getComponent("elements.Spinner"); + const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + + try { + await GroupStore.leaveGroup(this.props.group.groupId); + } catch (e) { + console.error("Error rejecting community invite: ", e); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Error rejecting invite', '', ErrorDialog, { + title: _t("Error"), + description: _t("Unable to reject invite"), + }); + } finally { + modal.close(); + } + }, + }); + + // Close the context menu + if (this.props.onFinished) { + this.props.onFinished(); + } + } + + render() { + return
+
+ + { _t('Reject') } +
+
; + } +} diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 99ec493ced..be718050c1 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -15,10 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - import React from 'react'; import PropTypes from 'prop-types'; +import {EventStatus} from 'matrix-js-sdk'; import MatrixClientPeg from '../../../MatrixClientPeg'; import dis from '../../../dispatcher'; @@ -179,7 +178,16 @@ module.exports = React.createClass({ onQuoteClick: function() { dis.dispatch({ action: 'quote', - text: this.props.eventTileOps.getInnerText(), + event: this.props.mxEvent, + }); + this.closeMenu(); + }, + + onPermalinkClick: function(e: Event) { + e.preventDefault(); + const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); + Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { + target: this.props.mxEvent, }); this.closeMenu(); }, @@ -211,7 +219,10 @@ module.exports = React.createClass({ let replyButton; let collapseReplyThread; - if (eventStatus === 'not_sent') { + // status is SENT before remote-echo, null after + const isSent = !eventStatus || eventStatus === EventStatus.SENT; + + if (eventStatus === EventStatus.NOT_SENT) { resendButton = (
{ _t('Resend') } @@ -219,7 +230,7 @@ module.exports = React.createClass({ ); } - if (!eventStatus && this.state.canRedact) { + if (isSent && this.state.canRedact) { redactButton = (
{ _t('Remove') } @@ -227,7 +238,7 @@ module.exports = React.createClass({ ); } - if (eventStatus === "queued" || eventStatus === "not_sent") { + if (eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT) { cancelButton = (
{ _t('Cancel Sending') } @@ -235,7 +246,7 @@ module.exports = React.createClass({ ); } - if (!eventStatus && this.props.mxEvent.getType() === 'm.room.message') { + if (isSent && this.props.mxEvent.getType() === 'm.room.message') { const content = this.props.mxEvent.getContent(); if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) { forwardButton = ( @@ -244,13 +255,11 @@ module.exports = React.createClass({
); - if (SettingsStore.isFeatureEnabled("feature_rich_quoting")) { - replyButton = ( -
- { _t('Reply') } -
- ); - } + replyButton = ( +
+ { _t('Reply') } +
+ ); if (this.state.canPin) { pinButton = ( @@ -290,7 +299,7 @@ module.exports = React.createClass({ const permalinkButton = (
{ _t('Permalink') } + target="_blank" rel="noopener" onClick={this.onPermalinkClick}>{ _t('Share Message') }
); diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 0d0b7456b5..abc52f7b1d 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import Promise from 'bluebird'; @@ -27,6 +27,13 @@ import GroupStore from '../../../stores/GroupStore'; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; +const addressTypeName = { + 'mx-user-id': _td("Matrix ID"), + 'mx-room-id': _td("Matrix Room ID"), + 'email': _td("email address"), +}; + + module.exports = React.createClass({ displayName: "AddressPickerDialog", @@ -66,7 +73,7 @@ module.exports = React.createClass({ // List of UserAddressType objects representing // the list of addresses we're going to invite - userList: [], + selectedList: [], // Whether a search is ongoing busy: false, @@ -76,10 +83,9 @@ module.exports = React.createClass({ serverSupportsUserDirectory: true, // The query being searched for query: "", - // List of UserAddressType objects representing - // the set of auto-completion results for the current search - // query. - queryList: [], + // List of UserAddressType objects representing the set of + // auto-completion results for the current search query. + suggestedList: [], }; }, @@ -91,14 +97,14 @@ module.exports = React.createClass({ }, onButtonClick: function() { - let userList = this.state.userList.slice(); + let selectedList = this.state.selectedList.slice(); // Check the text input field to see if user has an unconverted address - // If there is and it's valid add it to the local userList + // If there is and it's valid add it to the local selectedList if (this.refs.textinput.value !== '') { - userList = this._addInputToList(); - if (userList === null) return; + selectedList = this._addInputToList(); + if (selectedList === null) return; } - this.props.onFinished(true, userList); + this.props.onFinished(true, selectedList); }, onCancel: function() { @@ -118,18 +124,18 @@ module.exports = React.createClass({ e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.moveSelectionDown(); - } else if (this.state.queryList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab + } else if (this.state.suggestedList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.chooseSelection(); - } else if (this.refs.textinput.value.length === 0 && this.state.userList.length && e.keyCode === 8) { // backspace + } else if (this.refs.textinput.value.length === 0 && this.state.selectedList.length && e.keyCode === 8) { // backspace e.stopPropagation(); e.preventDefault(); - this.onDismissed(this.state.userList.length - 1)(); + this.onDismissed(this.state.selectedList.length - 1)(); } else if (e.keyCode === 13) { // enter e.stopPropagation(); e.preventDefault(); - if (this.refs.textinput.value == '') { + if (this.refs.textinput.value === '') { // if there's nothing in the input box, submit the form this.onButtonClick(); } else { @@ -148,7 +154,7 @@ module.exports = React.createClass({ clearTimeout(this.queryChangedDebouncer); } // Only do search if there is something to search - if (query.length > 0 && query != '@' && query.length >= 2) { + if (query.length > 0 && query !== '@' && query.length >= 2) { this.queryChangedDebouncer = setTimeout(() => { if (this.props.pickerType === 'user') { if (this.props.groupId) { @@ -170,7 +176,7 @@ module.exports = React.createClass({ }, QUERY_USER_DIRECTORY_DEBOUNCE_MS); } else { this.setState({ - queryList: [], + suggestedList: [], query: "", searchError: null, }); @@ -179,11 +185,11 @@ module.exports = React.createClass({ onDismissed: function(index) { return () => { - const userList = this.state.userList.slice(); - userList.splice(index, 1); + const selectedList = this.state.selectedList.slice(); + selectedList.splice(index, 1); this.setState({ - userList: userList, - queryList: [], + selectedList, + suggestedList: [], query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); @@ -197,11 +203,11 @@ module.exports = React.createClass({ }, onSelected: function(index) { - const userList = this.state.userList.slice(); - userList.push(this.state.queryList[index]); + const selectedList = this.state.selectedList.slice(); + selectedList.push(this.state.suggestedList[index]); this.setState({ - userList: userList, - queryList: [], + selectedList, + suggestedList: [], query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); @@ -379,10 +385,10 @@ module.exports = React.createClass({ }, _processResults: function(results, query) { - const queryList = []; + const suggestedList = []; results.forEach((result) => { if (result.room_id) { - queryList.push({ + suggestedList.push({ addressType: 'mx-room-id', address: result.room_id, displayName: result.name, @@ -399,7 +405,7 @@ module.exports = React.createClass({ // Return objects, structure of which is defined // by UserAddressType - queryList.push({ + suggestedList.push({ addressType: 'mx-user-id', address: result.user_id, displayName: result.display_name, @@ -413,18 +419,18 @@ module.exports = React.createClass({ // a perfectly valid address if there are close matches. const addrType = getAddressType(query); if (this.props.validAddressTypes.includes(addrType)) { - queryList.unshift({ + suggestedList.unshift({ addressType: addrType, address: query, isKnown: false, }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - if (addrType == 'email') { + if (addrType === 'email') { this._lookupThreepid(addrType, query).done(); } } this.setState({ - queryList, + suggestedList, error: false, }, () => { if (this.addressSelector) this.addressSelector.moveSelectionTop(); @@ -442,14 +448,14 @@ module.exports = React.createClass({ if (!this.props.validAddressTypes.includes(addrType)) { this.setState({ error: true }); return null; - } else if (addrType == 'mx-user-id') { + } else if (addrType === 'mx-user-id') { const user = MatrixClientPeg.get().getUser(addrObj.address); if (user) { addrObj.displayName = user.displayName; addrObj.avatarMxc = user.avatarUrl; addrObj.isKnown = true; } - } else if (addrType == 'mx-room-id') { + } else if (addrType === 'mx-room-id') { const room = MatrixClientPeg.get().getRoom(addrObj.address); if (room) { addrObj.displayName = room.name; @@ -458,15 +464,15 @@ module.exports = React.createClass({ } } - const userList = this.state.userList.slice(); - userList.push(addrObj); + const selectedList = this.state.selectedList.slice(); + selectedList.push(addrObj); this.setState({ - userList: userList, - queryList: [], + selectedList, + suggestedList: [], query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - return userList; + return selectedList; }, _lookupThreepid: function(medium, address) { @@ -492,7 +498,7 @@ module.exports = React.createClass({ if (res === null) return null; if (cancelled) return null; this.setState({ - queryList: [{ + suggestedList: [{ // a UserAddressType addressType: medium, address: address, @@ -510,15 +516,27 @@ module.exports = React.createClass({ const AddressSelector = sdk.getComponent("elements.AddressSelector"); this.scrollElement = null; + // map addressType => set of addresses to avoid O(n*m) operation + const selectedAddresses = {}; + this.state.selectedList.forEach(({address, addressType}) => { + if (!selectedAddresses[addressType]) selectedAddresses[addressType] = new Set(); + selectedAddresses[addressType].add(address); + }); + + // Filter out any addresses in the above already selected addresses (matching both type and address) + const filteredSuggestedList = this.state.suggestedList.filter(({address, addressType}) => { + return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address)); + }); + const query = []; // create the invite list - if (this.state.userList.length > 0) { + if (this.state.selectedList.length > 0) { const AddressTile = sdk.getComponent("elements.AddressTile"); - for (let i = 0; i < this.state.userList.length; i++) { + for (let i = 0; i < this.state.selectedList.length; i++) { query.push( , @@ -528,7 +546,7 @@ module.exports = React.createClass({ // Add the query at the end query.push( -