Merge remote-tracking branch 'origin/develop' into matthew/e2e_backups

This commit is contained in:
David Baker 2018-08-24 14:07:16 +01:00
commit 8fd7c4a66b
231 changed files with 13885 additions and 11701 deletions

View File

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

View File

@ -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",

3
.gitignore vendored
View File

@ -14,3 +14,6 @@ npm-debug.log
/src/component-index.js
.DS_Store
# https://github.com/vector-im/riot-web/issues/7083
package-lock.json

View File

@ -10,7 +10,7 @@ RIOT_WEB_DIR=riot-web
REACT_SDK_DIR=`pwd`
scripts/fetchdep.sh vector-im riot-web
cd "$RIOT_WEB_DIR"
pushd "$RIOT_WEB_DIR"
mkdir node_modules
npm install
@ -23,4 +23,16 @@ ln -s "$REACT_SDK_DIR/node_modules/matrix-js-sdk" node_modules/matrix-js-sdk
rm -r node_modules/matrix-react-sdk
ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk
npm run build
npm run test
popd
# run end to end tests
git clone https://github.com/matrix-org/matrix-react-end-to-end-tests.git --branch master
pushd matrix-react-end-to-end-tests
ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web
# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
# CHROME_PATH=$(which google-chrome-stable) ./run.sh
./install.sh
./run.sh
popd

View File

@ -15,5 +15,7 @@ addons:
chrome: stable
install:
- npm install
# install synapse prerequisites for end to end tests
- sudo apt-get install build-essential python2.7-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev
script:
./scripts/travis.sh

View File

@ -1,3 +1,462 @@
Changes in [0.13.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.2) (2018-08-23)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.1...v0.13.2)
* Don't crash if the value of a room tag is null
[\#2135](https://github.com/matrix-org/matrix-react-sdk/pull/2135)
Changes in [0.13.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.1) (2018-08-20)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.1-rc.1...v0.13.1)
* No changes since rc.1
Changes in [0.13.1-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.1-rc.1) (2018-08-16)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.0...v0.13.1-rc.1)
* Update from Weblate.
[\#2121](https://github.com/matrix-org/matrix-react-sdk/pull/2121)
* Shift to M_RESOURCE_LIMIT_EXCEEDED errors
[\#2120](https://github.com/matrix-org/matrix-react-sdk/pull/2120)
* Fix RoomSettings test
[\#2119](https://github.com/matrix-org/matrix-react-sdk/pull/2119)
* Show room version number in room settings
[\#2117](https://github.com/matrix-org/matrix-react-sdk/pull/2117)
* Warning bar for MAU limit hit
[\#2114](https://github.com/matrix-org/matrix-react-sdk/pull/2114)
* Recognise server notices room(s)
[\#2112](https://github.com/matrix-org/matrix-react-sdk/pull/2112)
* Update room tags behaviour to match spec more
[\#2111](https://github.com/matrix-org/matrix-react-sdk/pull/2111)
* while logging out ignore `Session.logged_out` as it is intentional
[\#2058](https://github.com/matrix-org/matrix-react-sdk/pull/2058)
* Don't show 'connection lost' bar on MAU error
[\#2110](https://github.com/matrix-org/matrix-react-sdk/pull/2110)
* Support MAU error on sync
[\#2108](https://github.com/matrix-org/matrix-react-sdk/pull/2108)
* Support active user limit on message send
[\#2106](https://github.com/matrix-org/matrix-react-sdk/pull/2106)
* Run end to end tests as part of Travis build
[\#2091](https://github.com/matrix-org/matrix-react-sdk/pull/2091)
* Remove package-lock.json for now
[\#2097](https://github.com/matrix-org/matrix-react-sdk/pull/2097)
* Support montly active user limit error on /login
[\#2103](https://github.com/matrix-org/matrix-react-sdk/pull/2103)
* Unpin sanitize-html
[\#2105](https://github.com/matrix-org/matrix-react-sdk/pull/2105)
* Pin sanitize-html to 0.18.2
[\#2101](https://github.com/matrix-org/matrix-react-sdk/pull/2101)
* Make clicking on side panels close settings (mk 3)
[\#2096](https://github.com/matrix-org/matrix-react-sdk/pull/2096)
* Fix persistent element location not updating
[\#2092](https://github.com/matrix-org/matrix-react-sdk/pull/2092)
* fix Devtools input autofocus && state traversal when len === 1 && key=""
[\#2090](https://github.com/matrix-org/matrix-react-sdk/pull/2090)
* allow autocompleting Emoji by common aliases, e.g :+1: to :thumbsup:
[\#2085](https://github.com/matrix-org/matrix-react-sdk/pull/2085)
Changes in [0.13.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.0) (2018-07-30)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.0-rc.2...v0.13.0)
* Fix composer bug where cursor position would change when Riot regained focus
[\#2093](https://github.com/matrix-org/matrix-react-sdk/pull/2093)
* Fix persistend element location not updating
[\#2094](https://github.com/matrix-org/matrix-react-sdk/pull/2094)
* Slate Fixes 42?
[\#2089](https://github.com/matrix-org/matrix-react-sdk/pull/2089)
Changes in [0.13.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.0-rc.2) (2018-07-24)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.0-rc.1...v0.13.0-rc.2)
* Take jitsi conf calling out of labs
[\#2087](https://github.com/matrix-org/matrix-react-sdk/pull/2087)
Changes in [0.13.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.0-rc.1) (2018-07-24)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.9...v0.13.0-rc.1)
* Update from Weblate.
[\#2086](https://github.com/matrix-org/matrix-react-sdk/pull/2086)
* Moar Slate Fixes
[\#2082](https://github.com/matrix-org/matrix-react-sdk/pull/2082)
* Destroy the widget when its permission is revoked
[\#2081](https://github.com/matrix-org/matrix-react-sdk/pull/2081)
* Make ActiveWidgetStore clear persistent widgets
[\#2084](https://github.com/matrix-org/matrix-react-sdk/pull/2084)
* CreateRoomDialog is rendered before getting the config default_federate
[\#2078](https://github.com/matrix-org/matrix-react-sdk/pull/2078)
* Slate Fixes
[\#2076](https://github.com/matrix-org/matrix-react-sdk/pull/2076)
* FIX: Don't error on rooms the user has left already
[\#2077](https://github.com/matrix-org/matrix-react-sdk/pull/2077)
* Fix persistent apps being the wrong size
[\#2080](https://github.com/matrix-org/matrix-react-sdk/pull/2080)
* Fix widgets resetting when going to the top-left
[\#2079](https://github.com/matrix-org/matrix-react-sdk/pull/2079)
* Jitsi: Use integrations URL from config
[\#2062](https://github.com/matrix-org/matrix-react-sdk/pull/2062)
* Allow jitsi in e2e rooms
[\#2075](https://github.com/matrix-org/matrix-react-sdk/pull/2075)
* Fix border around persisted widgets
[\#2071](https://github.com/matrix-org/matrix-react-sdk/pull/2071)
* Fix e2e icons floating above jitsi
[\#2073](https://github.com/matrix-org/matrix-react-sdk/pull/2073)
* hide some commands after space as they have special semantics
[\#2074](https://github.com/matrix-org/matrix-react-sdk/pull/2074)
* Even More Slate Fixes :D
[\#2070](https://github.com/matrix-org/matrix-react-sdk/pull/2070)
* Improve UX for Jitsi by adding local echo for widgets
[\#2035](https://github.com/matrix-org/matrix-react-sdk/pull/2035)
* Jitsi: Check integrations server before call
[\#2063](https://github.com/matrix-org/matrix-react-sdk/pull/2063)
* Jitsi: Error message on no permission
[\#2061](https://github.com/matrix-org/matrix-react-sdk/pull/2061)
* Fix read receipts on top of Jitsi
[\#2065](https://github.com/matrix-org/matrix-react-sdk/pull/2065)
* Moar Slate Fixes
[\#2069](https://github.com/matrix-org/matrix-react-sdk/pull/2069)
* fix 2nd typo in one PR :(
[\#2068](https://github.com/matrix-org/matrix-react-sdk/pull/2068)
* check if has some completions, not if >=0
[\#2067](https://github.com/matrix-org/matrix-react-sdk/pull/2067)
* Slate fixes
[\#2066](https://github.com/matrix-org/matrix-react-sdk/pull/2066)
* Implement always-on-screen capability for widgets
[\#2056](https://github.com/matrix-org/matrix-react-sdk/pull/2056)
* simplify MessageComposerStore and improve its performance
[\#2064](https://github.com/matrix-org/matrix-react-sdk/pull/2064)
* Replace Draft with Slate
[\#1890](https://github.com/matrix-org/matrix-react-sdk/pull/1890)
* Fix not stopping to peek when navigating away from peeked room
[\#2055](https://github.com/matrix-org/matrix-react-sdk/pull/2055)
* T3chguy/slate cont2
[\#2049](https://github.com/matrix-org/matrix-react-sdk/pull/2049)
* add null-guard for stickerpickerWidget in StickerPicker
[\#2057](https://github.com/matrix-org/matrix-react-sdk/pull/2057)
* Implement always-on-screen capability for widgets
[\#2053](https://github.com/matrix-org/matrix-react-sdk/pull/2053)
* fix nullguard on EventTile, getComponent never returns falsey, it throws
[\#2024](https://github.com/matrix-org/matrix-react-sdk/pull/2024)
* Fix stickerpicker PersistedElement usage
[\#2051](https://github.com/matrix-org/matrix-react-sdk/pull/2051)
* encrypt for invited users if history visibility allows.
[\#2042](https://github.com/matrix-org/matrix-react-sdk/pull/2042)
* move nag bar clear statement to any desktop notif toggle not just 0->1
[\#2031](https://github.com/matrix-org/matrix-react-sdk/pull/2031)
* use TruncatedList to prevent rendering hundreds/thousands of DOM nodes
[\#2041](https://github.com/matrix-org/matrix-react-sdk/pull/2041)
* Fix stuff
[\#2047](https://github.com/matrix-org/matrix-react-sdk/pull/2047)
* Show m.room.server_acl
[\#2046](https://github.com/matrix-org/matrix-react-sdk/pull/2046)
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 `<pre>` 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)

88
docs/slate-formats.md Normal file
View File

@ -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 <p/> 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.

6666
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "0.12.5",
"version": "0.13.2",
"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,20 +70,26 @@
"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.8",
"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",
"sanitize-html": "^1.14.1",
"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.18.4",
"text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0",
"velocity-vector": "vector-im/velocity#059e3b2",
@ -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"

View File

@ -291,6 +291,10 @@ textarea {
vertical-align: middle;
}
.mx_emojione_selected {
background-color: $accent-color;
}
::-moz-selection {
background-color: $accent-color;
color: $selection-fg-color;

View File

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

View File

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

View File

@ -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.
@ -54,6 +55,10 @@ limitations under the License.
}
.mx_LeftPanel .mx_AppTile_mini {
height: 132px;
}
.mx_LeftPanel .mx_RoomList_scrollbar {
order: 1;

View File

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

View File

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

View File

@ -176,10 +176,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 {

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -4,6 +4,7 @@
.mx_UserPill,
.mx_RoomPill,
.mx_GroupPill,
.mx_AtRoomPill {
border-radius: 16px;
display: inline-block;
@ -13,7 +14,8 @@
}
.mx_EventTile_body .mx_UserPill,
.mx_EventTile_body .mx_RoomPill {
.mx_EventTile_body .mx_RoomPill,
.mx_EventTile_body .mx_GroupPill {
cursor: pointer;
}
@ -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;

View File

@ -28,6 +28,18 @@ limitations under the License.
margin-top: -2px;
}
.mx_MatrixToolbar_info {
padding-left: 16px;
padding-right: 8px;
background-color: $info-bg-color;
}
.mx_MatrixToolbar_error {
padding-left: 16px;
padding-right: 8px;
background-color: $warning-bg-color;
}
.mx_MatrixToolbar_content {
flex: 1;
}
@ -59,4 +71,4 @@ limitations under the License.
.mx_MatrixToolbar_changelog {
white-space: pre;
}
}

View File

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

View File

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

View File

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

View File

@ -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;

View File

@ -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;
}

View File

@ -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 <pre> 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 {

View File

@ -70,6 +70,7 @@ limitations under the License.
flex: 1;
display: flex;
flex-direction: column;
cursor: text;
}
.mx_MessageComposer_input {
@ -78,12 +79,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 <p/> 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 }
@ -94,28 +112,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;
@ -123,7 +119,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;

View File

@ -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;
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 15.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="612px" height="612px" viewBox="0 90 612 612" enable-background="new 0 90 612 612" xml:space="preserve">
<path stroke="#76CFA6" fill="#76CFA6" stroke-width="40" stroke-miterlimit="10" d="M517.593,435.2c-9.204,0-17.093,7.053-17.811,16.257
c-8.247,99.33-91.8,176.786-193.401,176.786c-106.98,0-194.119-86.54-194.119-192.923c0-104.71,84.389-190.294,189.098-192.924
c2.75-0.12,4.901,2.032,4.901,4.781v60.124c0,15.061,16.614,24.146,29.404,16.137l114.989-80.444
c11.953-7.53,11.953-24.862,0-32.393l-114.869-79.369c-12.79-8.009-29.405,1.076-29.405,16.137v54.626
c0,2.629-2.032,4.781-4.661,4.781C176.929,209.286,76.522,310.649,76.522,435.32c0,126.225,102.917,228.424,229.858,228.424
c120.487,0,219.221-91.681,229.022-209.299C536.359,444.046,527.992,435.2,517.593,435.2L517.593,435.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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

After

Width:  |  Height:  |  Size: 1.1 KiB

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

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -19,6 +19,8 @@ $focus-brightness: 200%;
// red warning colour
$warning-color: #ff0064;
$warning-bg-color: #DF2A8B;
$info-bg-color: #2A9EDF;
// groups
$info-plinth-bg-color: #454545;

View File

@ -25,6 +25,9 @@ $focus-brightness: 125%;
// red warning colour
$warning-color: #ff0064;
// background colour for warnings
$warning-bg-color: #DF2A8B;
$info-bg-color: #2A9EDF;
$mention-user-pill-bg-color: #ff0064;
$other-user-pill-bg-color: rgba(0, 0, 0, 0.1);
@ -97,6 +100,7 @@ $voip-accept-color: #80f480;
$rte-bg-color: #e9e9e9;
$rte-code-bg-color: rgba(0, 0, 0, 0.04);
$rte-room-pill-color: #aaa;
$rte-group-pill-color: #aaa;
// ********************

View File

@ -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;
}

View File

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

View File

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -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;
},

View File

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

View File

@ -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<HistoryItem> = [];
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];
}
}

View File

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

View File

@ -0,0 +1,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;
}
}
}
}

View File

@ -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'});

View File

@ -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';

View File

@ -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 += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> 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 += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} 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;

View File

@ -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
@ -385,6 +386,8 @@ function _persistCredentialsToLocalStorage(credentials) {
console.log(`Session persisted for ${credentials.userId}`);
}
let _isLoggingOut = false;
/**
* Logs the current session out and transitions to the logged-out state
*/
@ -404,6 +407,7 @@ export function logout() {
return;
}
_isLoggingOut = true;
MatrixClientPeg.get().logout().then(onLoggedOut,
(err) => {
// Just throwing an error here is going to be very unhelpful
@ -419,6 +423,10 @@ export function logout() {
).done();
}
export function isLoggingOut() {
return _isLoggingOut;
}
/**
* Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in.
@ -436,6 +444,7 @@ async function startMatrixClient() {
UserActivity.start();
Presence.start();
DMRoomMap.makeShared().start();
ActiveWidgetStore.start();
await MatrixClientPeg.start();
@ -449,6 +458,7 @@ async function startMatrixClient() {
* storage. Used after a session has been logged out.
*/
export function onLoggedOut() {
_isLoggingOut = false;
stopMatrixClient();
_clearStorage().done();
dis.dispatch({action: 'on_logged_out'});
@ -488,6 +498,7 @@ export function stopMatrixClient() {
Notifier.stop();
UserActivity.stop();
Presence.stop();
ActiveWidgetStore.stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop();
const cli = MatrixClientPeg.get();
if (cli) {

View File

@ -102,6 +102,16 @@ export default class Markdown {
// (https://github.com/vector-im/riot-web/issues/3154)
softbreak: '<br />',
});
// Trying to strip out the wrapping <p/> causes a lot more complication
// than it's worth, i think. For instance, this code will go and strip
// out any <p/> tag (no matter where it is in the tree) which doesn't
// contain \n's.
// On the flip side, <p/>s are quite opionated and restricted on where
// you can nest them.
//
// Let's try sending with <p/>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');

View File

@ -99,6 +99,10 @@ class MatrixClientPeg {
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached";
if (SettingsStore.isFeatureEnabled('feature_lazyloading')) {
opts.lazyLoadMembers = true;
}
try {
const promise = this.matrixClient.store.startup();
console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`);
@ -115,7 +119,7 @@ class MatrixClientPeg {
MatrixActionCreators.start(this.matrixClient);
console.log(`MatrixClientPeg: really starting MatrixClient`);
this.get().startClient(opts);
await this.get().startClient(opts);
console.log(`MatrixClientPeg: MatrixClient started`);
}

View File

@ -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() {

View File

@ -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 (<span title={shortname} style={style}><span style={{opacity: 0}}>{ props.children }</span></span>);
},
};
/**
* 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) => (
<span className={"mx_MarkdownElement mx_Markdown_" + style}>
{ props.children }
</span>
),
}));
markdownDecorators.push({
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
},
component: (props) => (
<a href="#" className="mx_MarkdownElement mx_Markdown_LINK">
{ props.children }
</a>
),
});
// 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<ContentBlock>): {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<ContentBlock>): 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');
}

View File

@ -191,14 +191,10 @@ 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 room.getMyMembership() === 'join';
}
});
return rooms;

View File

@ -31,27 +31,27 @@ export function getDisplayAliasForRoom(room) {
* If the room contains only two members including the logged-in user,
* return the other one. Otherwise, return null.
*/
export function getOnlyOtherMember(room, me) {
const joinedMembers = room.getJoinedMembers();
export function getOnlyOtherMember(room, myUserId) {
if (joinedMembers.length === 2) {
return joinedMembers.filter(function(m) {
return m.userId !== me.userId;
if (room.currentState.getJoinedMemberCount() === 2) {
return room.getJoinedMembers().filter(function(m) {
return m.userId !== myUserId;
})[0];
}
return null;
}
function _isConfCallRoom(room, me, conferenceHandler) {
function _isConfCallRoom(room, myUserId, conferenceHandler) {
if (!conferenceHandler) return false;
if (me.membership != "join") {
const myMembership = room.getMyMembership();
if (myMembership != "join") {
return false;
}
const otherMember = getOnlyOtherMember(room, me);
if (otherMember === null) {
const otherMember = getOnlyOtherMember(room, myUserId);
if (!otherMember) {
return false;
}
@ -68,29 +68,31 @@ const isConfCallRoomCache = {
// $roomId: bool
};
export function isConfCallRoom(room, me, conferenceHandler) {
export function isConfCallRoom(room, myUserId, conferenceHandler) {
if (isConfCallRoomCache[room.roomId] !== undefined) {
return isConfCallRoomCache[room.roomId];
}
const result = _isConfCallRoom(room, me, conferenceHandler);
const result = _isConfCallRoom(room, myUserId, conferenceHandler);
isConfCallRoomCache[room.roomId] = result;
return result;
}
export function looksLikeDirectMessageRoom(room, me) {
if (me.membership == "join" || me.membership === "ban" ||
(me.membership === "leave" && me.events.member.getSender() !== me.events.member.getStateKey())) {
export function looksLikeDirectMessageRoom(room, myUserId) {
const myMembership = room.getMyMembership();
const me = room.getMember(myUserId);
if (myMembership == "join" || myMembership === "ban" || (me && me.isKicked())) {
// Used to split rooms via tags
const tagNames = Object.keys(room.tags);
// Used for 1:1 direct chats
const members = room.currentState.getMembers();
// Show 1:1 chats in seperate "Direct Messages" section as long as they haven't
// been moved to a different tag section
if (members.length === 2 && !tagNames.length) {
const totalMemberCount = room.currentState.getJoinedMemberCount() +
room.currentState.getInvitedMemberCount();
if (totalMemberCount === 2 && !tagNames.length) {
return true;
}
}
@ -100,10 +102,10 @@ export function looksLikeDirectMessageRoom(room, me) {
export function guessAndSetDMRoom(room, isDirect) {
let newTarget;
if (isDirect) {
const guessedTarget = guessDMRoomTarget(
room, room.getMember(MatrixClientPeg.get().credentials.userId),
const guessedUserId = guessDMRoomTargetId(
room, MatrixClientPeg.get().getUserId()
);
newTarget = guessedTarget.userId;
newTarget = guessedUserId;
} else {
newTarget = null;
}
@ -159,15 +161,15 @@ export function setDMRoom(roomId, userId) {
* Given a room, estimate which of its members is likely to
* be the target if the room were a DM room and return that user.
*/
export function guessDMRoomTarget(room, me) {
function guessDMRoomTargetId(room, myUserId) {
let oldestTs;
let oldestUser;
// Pick the joined user who's been here longest (and isn't us),
for (const user of room.getJoinedMembers()) {
if (user.userId == me.userId) continue;
if (user.userId == myUserId) continue;
if (oldestTs === undefined || user.events.member.getTs() < oldestTs) {
if (oldestTs === undefined || (user.events.member && user.events.member.getTs() < oldestTs)) {
oldestUser = user;
oldestTs = user.events.member.getTs();
}
@ -176,14 +178,14 @@ export function guessDMRoomTarget(room, me) {
// if there are no joined members other than us, use the oldest member
for (const user of room.currentState.getMembers()) {
if (user.userId == me.userId) continue;
if (user.userId == myUserId) continue;
if (oldestTs === undefined || user.events.member.getTs() < oldestTs) {
if (oldestTs === undefined || (user.events.member && user.events.member.getTs() < oldestTs)) {
oldestUser = user;
oldestTs = user.events.member.getTs();
}
}
if (oldestUser === undefined) return me;
if (oldestUser === undefined) return myUserId;
return oldestUser;
}

View File

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

View File

@ -14,28 +14,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", "<query>", function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
});
return success();
export const CommandMap = {
ddg: new Command({
name: 'ddg',
args: '<query>',
description: _td('Searches DuckDuckGo for results'),
runFn: function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, {
title: _t('/ddg is not a command'),
description: _t('To use it, just wait for autocomplete results to load and tab through them.'),
});
return success();
},
hideCompletionAfterSpace: true,
}),
// Change your nickname
nick: new Command("nick", "<display_name>", function(roomId, args) {
if (args) {
return success(
MatrixClientPeg.get().setDisplayName(args),
);
}
return reject(this.getUsage());
}),
// Changes the colorscheme of your current room
tint: new Command("tint", "<color1> [<color2>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/);
if (matches) {
Tinter.tint(matches[1], matches[4]);
const colorScheme = {};
colorScheme.primary_color = matches[1];
if (matches[4]) {
colorScheme.secondary_color = matches[4];
} else {
colorScheme.secondary_color = colorScheme.primary_color;
}
return success(
SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
);
nick: new Command({
name: 'nick',
args: '<display_name>',
description: _td('Changes your display nickname'),
runFn: function(roomId, args) {
if (args) {
return success(MatrixClientPeg.get().setDisplayName(args));
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Change the room topic
topic: new Command("topic", "<topic>", function(roomId, args) {
if (args) {
return success(
MatrixClientPeg.get().setRoomTopic(roomId, args),
);
}
return reject(this.getUsage());
}),
// Invite a user
invite: new Command("invite", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
return success(
MatrixClientPeg.get().invite(roomId, matches[1]),
);
}
}
return reject(this.getUsage());
}),
// Join a room
join: new Command("join", "#alias:domain", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') {
return reject(this.getUsage());
}
if (!roomAlias.match(/:/)) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
dis.dispatch({
action: 'view_room',
room_alias: roomAlias,
auto_join: true,
});
return success();
}
}
return reject(this.getUsage());
}),
part: new Command("part", "[#alias:domain]", function(roomId, args) {
let targetRoomId;
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') {
return reject(this.getUsage());
}
if (!roomAlias.match(/:/)) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
// Try to find a room with this alias
const rooms = MatrixClientPeg.get().getRooms();
for (let i = 0; i < rooms.length; i++) {
const aliasEvents = rooms[i].currentState.getStateEvents(
"m.room.aliases",
);
for (let j = 0; j < aliasEvents.length; j++) {
const aliases = aliasEvents[j].getContent().aliases || [];
for (let k = 0; k < aliases.length; k++) {
if (aliases[k] === roomAlias) {
targetRoomId = rooms[i].roomId;
break;
}
}
if (targetRoomId) { break; }
tint: new Command({
name: 'tint',
args: '<color1> [<color2>]',
description: _td('Changes colour scheme of current room'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(#([\da-fA-F]{3}|[\da-fA-F]{6}))( +(#([\da-fA-F]{3}|[\da-fA-F]{6})))?$/);
if (matches) {
Tinter.tint(matches[1], matches[4]);
const colorScheme = {};
colorScheme.primary_color = matches[1];
if (matches[4]) {
colorScheme.secondary_color = matches[4];
} else {
colorScheme.secondary_color = colorScheme.primary_color;
}
if (targetRoomId) { break; }
}
if (!targetRoomId) {
return reject(_t("Unrecognised room alias:") + ' ' + roomAlias);
return success(
SettingsStore.setValue('roomColor', roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
);
}
}
}
if (!targetRoomId) targetRoomId = roomId;
return success(
MatrixClientPeg.get().leave(targetRoomId).then(
function() {
dis.dispatch({action: 'view_next_room'});
},
),
);
return reject(this.getUsage());
},
}),
// Kick a user from the room with an optional reason
kick: new Command("kick", "<userId> [<reason>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(
MatrixClientPeg.get().kick(roomId, matches[1], matches[3]),
);
topic: new Command({
name: 'topic',
args: '<topic>',
description: _td('Sets the room topic'),
runFn: function(roomId, args) {
if (args) {
return success(MatrixClientPeg.get().setRoomTopic(roomId, args));
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
invite: new Command({
name: 'invite',
args: '<user-id>',
description: _td('Invites user with given id to current room'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
return success(MatrixClientPeg.get().invite(roomId, matches[1]));
}
}
return reject(this.getUsage());
},
}),
join: new Command({
name: 'join',
args: '<room-alias>',
description: _td('Joins room with given alias'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') return reject(this.getUsage());
if (!roomAlias.includes(':')) {
roomAlias += ':' + MatrixClientPeg.get().getDomain();
}
dis.dispatch({
action: 'view_room',
room_alias: roomAlias,
auto_join: true,
});
return success();
}
}
return reject(this.getUsage());
},
}),
part: new Command({
name: 'part',
args: '[<room-alias>]',
description: _td('Leave room'),
runFn: function(roomId, args) {
const cli = MatrixClientPeg.get();
let targetRoomId;
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
let roomAlias = matches[1];
if (roomAlias[0] !== '#') return reject(this.getUsage());
if (!roomAlias.includes(':')) {
roomAlias += ':' + cli.getDomain();
}
// Try to find a room with this alias
const rooms = cli.getRooms();
for (let i = 0; i < rooms.length; i++) {
const aliasEvents = rooms[i].currentState.getStateEvents('m.room.aliases');
for (let j = 0; j < aliasEvents.length; j++) {
const aliases = aliasEvents[j].getContent().aliases || [];
for (let k = 0; k < aliases.length; k++) {
if (aliases[k] === roomAlias) {
targetRoomId = rooms[i].roomId;
break;
}
}
if (targetRoomId) break;
}
if (targetRoomId) break;
}
if (!targetRoomId) return reject(_t('Unrecognised room alias:') + ' ' + roomAlias);
}
}
if (!targetRoomId) targetRoomId = roomId;
return success(
cli.leave(targetRoomId).then(function() {
dis.dispatch({action: 'view_next_room'});
}),
);
},
}),
kick: new Command({
name: 'kick',
args: '<user-id> [reason]',
description: _td('Kicks user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(MatrixClientPeg.get().kick(roomId, matches[1], matches[3]));
}
}
return reject(this.getUsage());
},
}),
// Ban a user from the room with an optional reason
ban: new Command("ban", "<userId> [<reason>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(
MatrixClientPeg.get().ban(roomId, matches[1], matches[3]),
);
ban: new Command({
name: 'ban',
args: '<user-id> [reason]',
description: _td('Bans user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
return success(MatrixClientPeg.get().ban(roomId, matches[1], matches[3]));
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Unban a user from the room
unban: new Command("unban", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
// Reset the user membership to "leave" to unban him
return success(
MatrixClientPeg.get().unban(roomId, matches[1]),
);
// Unban a user from ythe room
unban: new Command({
name: 'unban',
args: '<user-id>',
description: _td('Unbans user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
// Reset the user membership to "leave" to unban him
return success(MatrixClientPeg.get().unban(roomId, matches[1]));
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
ignore: new Command("ignore", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, {
title: _t("Ignored user"),
description: (
<div>
<p>{ _t("You are now ignoring %(userId)s", {userId: userId}) }</p>
</div>
),
hasCancelButton: false,
});
}),
);
ignore: new Command({
name: 'ignore',
args: '<user-id>',
description: _td('Ignores a user, hiding their messages from you'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(userId); // de-duped internally in the js-sdk
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, {
title: _t('Ignored user'),
description: <div>
<p>{ _t('You are now ignoring %(userId)s', {userId}) }</p>
</div>,
hasCancelButton: false,
});
}),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
unignore: new Command("unignore", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers();
const index = ignoredUsers.indexOf(userId);
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, {
title: _t("Unignored user"),
description: (
<div>
<p>{ _t("You are no longer ignoring %(userId)s", {userId: userId}) }</p>
</div>
),
hasCancelButton: false,
});
}),
);
unignore: new Command({
name: 'unignore',
args: '<user-id>',
description: _td('Stops ignoring a user, showing their messages going forward'),
runFn: function(roomId, args) {
if (args) {
const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/);
if (matches) {
const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers();
const index = ignoredUsers.indexOf(userId);
if (index !== -1) ignoredUsers.splice(index, 1);
return success(
cli.setIgnoredUsers(ignoredUsers).then(() => {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, {
title: _t('Unignored user'),
description: <div>
<p>{ _t('You are no longer ignoring %(userId)s', {userId}) }</p>
</div>,
hasCancelButton: false,
});
}),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Define the power level of a user
op: new Command("op", "<userId> [<power level>]", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
let powerLevel = 50; // default power level for op
if (matches) {
const userId = matches[1];
if (matches.length === 4 && undefined !== matches[3]) {
powerLevel = parseInt(matches[3]);
}
if (!isNaN(powerLevel)) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return reject("Bad room ID: " + roomId);
op: new Command({
name: 'op',
args: '<user-id> [<power-level>]',
description: _td('Define the power level of a user'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
let powerLevel = 50; // default power level for op
if (matches) {
const userId = matches[1];
if (matches.length === 4 && undefined !== matches[3]) {
powerLevel = parseInt(matches[3]);
}
if (!isNaN(powerLevel)) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
return success(cli.setPowerLevel(roomId, userId, powerLevel, powerLevelEvent));
}
const powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", "",
);
return success(
MatrixClientPeg.get().setPowerLevel(
roomId, userId, powerLevel, powerLevelEvent,
),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Reset the power level of a user
deop: new Command("deop", "<userId>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) {
return reject("Bad room ID: " + roomId);
}
deop: new Command({
name: 'deop',
args: '<user-id>',
description: _td('Deops user with given id'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+)$/);
if (matches) {
const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId);
if (!room) return reject('Bad room ID: ' + roomId);
const powerLevelEvent = room.currentState.getStateEvents(
"m.room.power_levels", "",
);
return success(
MatrixClientPeg.get().setPowerLevel(
roomId, args, undefined, powerLevelEvent,
),
);
const powerLevelEvent = room.currentState.getStateEvents('m.room.power_levels', '');
return success(cli.setPowerLevel(roomId, args, undefined, powerLevelEvent));
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Open developer tools
devtools: new Command("devtools", "", function(roomId) {
const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog");
Modal.createDialog(DevtoolsDialog, { roomId });
return success();
devtools: new Command({
name: 'devtools',
description: _td('Opens the Developer Tools dialog'),
runFn: function(roomId) {
const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog');
Modal.createDialog(DevtoolsDialog, {roomId});
return success();
},
}),
// Verify a user, device, and pubkey tuple
verify: new Command("verify", "<userId> <deviceId> <deviceSigningKey>", function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
if (matches) {
const userId = matches[1];
const deviceId = matches[2];
const fingerprint = matches[3];
verify: new Command({
name: 'verify',
args: '<user-id> <device-id> <device-signing-key>',
description: _td('Verifies a user, device, and pubkey tuple'),
runFn: function(roomId, args) {
if (args) {
const matches = args.match(/^(\S+) +(\S+) +(\S+)$/);
if (matches) {
const cli = MatrixClientPeg.get();
return success(
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(MatrixClientPeg.get().getStoredDevice(userId, deviceId)).then((device) => {
if (!device) {
throw new Error(_t(`Unknown (user, device) pair:`) + ` (${userId}, ${deviceId})`);
}
const userId = matches[1];
const deviceId = matches[2];
const fingerprint = matches[3];
if (device.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw new Error(_t(`Device already verified!`));
} else {
throw new Error(_t(`WARNING: Device already verified, but keys do NOT MATCH!`));
return success(
// Promise.resolve to handle transition from static result to promise; can be removed
// in future
Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => {
if (!device) {
throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`);
}
}
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new Error(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key' +
' "%(fingerprint)s". This could mean your communications are being intercepted!',
{deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint}));
}
if (device.isVerified()) {
if (device.getFingerprint() === fingerprint) {
throw new Error(_t('Device already verified!'));
} else {
throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!'));
}
}
return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, {
title: _t("Verified key"),
description: (
<div>
<p>
{
_t("The signing key you provided matches the signing key you received " +
"from %(userId)s's device %(deviceId)s. Device marked as verified.",
{userId: userId, deviceId: deviceId})
}
</p>
</div>
),
hasCancelButton: false,
});
}),
);
if (device.getFingerprint() !== fingerprint) {
const fprint = device.getFingerprint();
throw new Error(
_t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' +
' %(deviceId)s is "%(fprint)s" which does not match the provided key ' +
'"%(fingerprint)s". This could mean your communications are being intercepted!',
{
fprint,
userId,
deviceId,
fingerprint,
}));
}
return cli.setDeviceVerified(userId, deviceId, true);
}).then(() => {
// Tell the user we verified everything
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, {
title: _t('Verified key'),
description: <div>
<p>
{
_t('The signing key you provided matches the signing key you received ' +
'from %(userId)s\'s device %(deviceId)s. Device marked as verified.',
{userId, deviceId})
}
</p>
</div>,
hasCancelButton: false,
});
}),
);
}
}
}
return reject(this.getUsage());
return reject(this.getUsage());
},
}),
// Command definitions for autocompletion ONLY:
// /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
me: new Command({
name: 'me',
args: '<message>',
description: _td('Displays action'),
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", "<action>", function() {}));
cmds.push(new Command("markdown", "<on|off>", 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);
}
}

View File

@ -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,
};

View File

@ -72,7 +72,7 @@ ConferenceCall.prototype._getConferenceUserRoom = function() {
for (var i = 0; i < rooms.length; i++) {
var confUser = rooms[i].getMember(this.confUserId);
if (confUser && confUser.membership === "join" &&
rooms[i].getJoinedMembers().length === 2) {
rooms[i].getJoinedMemberCount() === 2) {
confRoom = rooms[i];
break;
}
@ -84,7 +84,7 @@ ConferenceCall.prototype._getConferenceUserRoom = function() {
preset: "private_chat",
invite: [this.confUserId]
}).then(function(res) {
return new Room(res.room_id);
return new Room(res.room_id, null, client.getUserId());
});
};

View File

@ -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);
}
}

View File

@ -1,7 +1,7 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -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,
},
};
}

View File

@ -1,6 +1,6 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,7 +18,9 @@ limitations under the License.
// @flow
import type {Component} from 'react';
import {Room} from 'matrix-js-sdk';
import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View File

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

View File

@ -1,7 +1,8 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,8 +27,9 @@ import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index';
import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../matrix-to";
import type {Completion, SelectionRange} from "./Autocompleter";
const ROOM_REGEX = /(?=#)(\S*)/g;
const ROOM_REGEX = /\B#\S*/g;
function score(query, space) {
const index = space.indexOf(query);
@ -46,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<Completion> {
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: (

View File

@ -2,7 +2,8 @@
/*
Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -23,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<RoomMember> = 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<Completion> {
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: (
<PillCompletion
@ -128,7 +126,7 @@ export default class UserProvider extends AutocompleteProvider {
return completions;
}
getName() {
getName(): string {
return '👥 ' + _t('Users');
}
@ -141,13 +139,9 @@ export default class UserProvider extends AutocompleteProvider {
}
const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = this.room.getJoinedMembers().filter((member) => {
if (member.userId !== currentUserId) return true;
});
this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
this.users = _sortBy(this.users, (member) =>
1E20 - lastSpoken[member.userId] || 1E20,
);
this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
this.matcher.setObjects(this.users);
}

View File

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

View File

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

View File

@ -432,11 +432,14 @@ export default React.createClass({
this._changeAvatarComponent = null;
this._initGroupStore(this.props.groupId, true);
this._dispatcherRef = dis.register(this._onAction);
},
componentWillUnmount: function() {
this._unmounted = true;
this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
dis.unregister(this._dispatcherRef);
},
componentWillReceiveProps: function(newProps) {
@ -559,16 +562,33 @@ export default React.createClass({
});
},
_onShareClick: function() {
const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
Modal.createTrackedDialog('share community dialog', '', ShareDialog, {
target: this._matrixClient.getGroup(this.props.groupId),
});
},
_onCancelClick: function() {
this._closeSettings();
},
_onAction(payload) {
switch (payload.action) {
// NOTE: close_settings is an app-wide dispatch; as it is dispatched from MatrixChat
case 'close_settings':
this.setState({
editing: false,
profileForm: null,
});
break;
default:
break;
}
},
_closeSettings() {
this.setState({
editing: false,
profileForm: null,
});
dis.dispatch({action: 'panel_disable'});
dis.dispatch({action: 'close_settings'});
},
_onNameChange: function(value) {
@ -1039,7 +1059,7 @@ export default React.createClass({
<input type="radio"
value={GROUP_JOINPOLICY_INVITE}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE}
onClick={this._onJoinableChange}
onChange={this._onJoinableChange}
/>
<div className="mx_GroupView_label_text">
{ _t('Only people who have been invited') }
@ -1051,7 +1071,7 @@ export default React.createClass({
<input type="radio"
value={GROUP_JOINPOLICY_OPEN}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN}
onClick={this._onJoinableChange}
onChange={this._onJoinableChange}
/>
<div className="mx_GroupView_label_text">
{ _t('Everyone') }
@ -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 = <span onClick={onGroupHeaderItemClick}>{ summary.profile.short_description }</span>;
}
}
if (this.state.editing) {
rightButtons.push(
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
@ -1218,6 +1235,11 @@ export default React.createClass({
</AccessibleButton>,
);
}
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button" onClick={this._onShareClick} title={_t('Share Community')} key="_shareButton">
<TintableSvg src="img/icons-share.svg" width="16" height="16" />
</AccessibleButton>,
);
if (this.props.collapsedRhs) {
rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button"
@ -1256,7 +1278,8 @@ export default React.createClass({
</div>
</div>
<GeminiScrollbarWrapper className="mx_GroupView_body">
{ bodyNodes }
{ this._getMembershipSection() }
{ this._getGroupSection() }
</GeminiScrollbarWrapper>
</div>
);

View File

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

View File

@ -1,7 +1,7 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -30,10 +30,16 @@ import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
// NB. this is just for server notices rather than pinned messages in general.
const MAX_PINNED_NOTICES_PER_ROOM = 2;
/**
* This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property.
@ -80,6 +86,8 @@ const LoggedInView = React.createClass({
return {
// use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'),
// any currently active server notice events
serverNoticeEvents: [],
};
},
@ -97,12 +105,18 @@ const LoggedInView = React.createClass({
);
this._setStateFromSessionStore();
this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
},
componentWillUnmount: function() {
document.removeEventListener('keydown', this._onKeyDown);
this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
if (this._sessionStoreToken) {
this._sessionStoreToken.remove();
}
@ -142,6 +156,56 @@ const LoggedInView = React.createClass({
}
},
onSync: function(syncState, oldSyncState, data) {
const oldErrCode = this.state.syncErrorData && this.state.syncErrorData.error && this.state.syncErrorData.error.errcode;
const newErrCode = data && data.error && data.error.errcode;
if (syncState === oldSyncState && oldErrCode === newErrCode) return;
if (syncState === 'ERROR') {
this.setState({
syncErrorData: data,
});
} else {
this.setState({
syncErrorData: null,
});
}
if (oldSyncState === 'PREPARED' && syncState === 'SYNCING') {
this._updateServerNoticeEvents();
}
},
onRoomStateEvents: function(ev, state) {
const roomLists = RoomListStore.getRoomLists();
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
this._updateServerNoticeEvents();
}
},
_updateServerNoticeEvents: async function() {
const roomLists = RoomListStore.getRoomLists();
if (!roomLists['m.server_notice']) return [];
const pinnedEvents = [];
for (const room of roomLists['m.server_notice']) {
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0);
const ev = timeline.getEvents().find(ev => ev.getId() === eventId);
if (ev) pinnedEvents.push(ev);
}
}
this.setState({
serverNoticeEvents: pinnedEvents,
});
},
_onKeyDown: function(ev) {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
@ -255,6 +319,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) {
const targetClasses = new Set(ev.target.className.split(' '));
if (
targetClasses.has('mx_MatrixChat') ||
targetClasses.has('mx_MatrixChat_middlePanel') ||
targetClasses.has('mx_RoomView')
) {
dis.dispatch({ action: 'close_settings' });
}
}
},
render: function() {
const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel');
@ -270,6 +350,7 @@ const LoggedInView = React.createClass({
const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
let page_element;
let right_panel = '';
@ -295,7 +376,7 @@ const LoggedInView = React.createClass({
case PageTypes.UserSettings:
page_element = <UserSettings
onClose={this.props.onUserSettingsClose}
onClose={this.props.onCloseAllSettings}
brand={this.props.config.brand}
referralBaseUrl={this.props.config.referralBaseUrl}
teamToken={this.props.teamToken}
@ -352,9 +433,26 @@ const LoggedInView = React.createClass({
break;
}
const usageLimitEvent = this.state.serverNoticeEvents.find((e) => {
return (
e && e.getType() === 'm.room.message' &&
e.getContent()['server_notice_type'] === 'm.server_notice.usage_limit_reached'
);
});
let topBar;
const isGuest = this.props.matrixClient.isGuest();
if (this.props.showCookieBar &&
if (this.state.syncErrorData && this.state.syncErrorData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
topBar = <ServerLimitBar kind='hard'
adminContact={this.state.syncErrorData.error.data.admin_contact}
limitType={this.state.syncErrorData.error.data.limit_type}
/>;
} else if (usageLimitEvent) {
topBar = <ServerLimitBar kind='soft'
adminContact={usageLimitEvent.getContent().admin_contact}
limitType={usageLimitEvent.getContent().limit_type}
/>;
} else if (this.props.showCookieBar &&
this.props.config.piwik
) {
const policyUrl = this.props.config.piwik.policyUrl || null;
@ -380,7 +478,7 @@ const LoggedInView = React.createClass({
}
return (
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers}>
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} onClick={this._onClick}>
{ topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div className={bodyClasses}>

View File

@ -23,6 +23,7 @@ import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import MatrixClientPeg from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig";
@ -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,
@ -1223,6 +1262,7 @@ export default React.createClass({
}, true);
});
cli.on('Session.logged_out', function(call) {
if (Lifecycle.isLoggingOut()) return;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
title: _t('Signed Out'),
@ -1265,6 +1305,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);
@ -1602,19 +1668,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) {
@ -1673,7 +1728,7 @@ export default React.createClass({
return (
<LoggedInView ref={this._collectLoggedInView} matrixClient={MatrixClientPeg.get()}
onRoomCreated={this.onRoomCreated}
onUserSettingsClose={this.onUserSettingsClose}
onCloseAllSettings={this.onCloseAllSettings}
onRegistered={this.onRegistered}
currentRoomId={this.state.currentRoomId}
teamToken={this._teamToken}

View File

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

View File

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

View File

@ -280,7 +280,7 @@ module.exports = React.createClass({
const room = cli.getRoom(this.props.roomId);
let isUserInRoom;
if (room) {
const numMembers = room.getJoinedMembers().length;
const numMembers = room.getJoinedMemberCount();
membersTitle = _t('%(count)s Members', { count: numMembers });
membersBadge = <div title={membersTitle}>{ formatCount(numMembers) }</div>;
isUserInRoom = room.hasMembershipState(this.context.matrixClient.credentials.userId, 'join');

View File

@ -1,6 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,13 +18,15 @@ limitations under the License.
import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk';
import { _t } from '../../languageHandler';
import { _t, _td } from '../../languageHandler';
import sdk from '../../index';
import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
@ -106,6 +108,7 @@ module.exports = React.createClass({
getInitialState: function() {
return {
syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(this.props.room),
};
@ -133,12 +136,13 @@ module.exports = React.createClass({
}
},
onSyncStateChange: function(state, prevState) {
onSyncStateChange: function(state, prevState, data) {
if (state === "SYNCING" && prevState === "SYNCING") {
return;
}
this.setState({
syncState: state,
syncStateData: data,
});
},
@ -157,10 +161,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() {
@ -188,7 +194,7 @@ module.exports = React.createClass({
// changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes.
_getSize: function() {
if (this.state.syncState === "ERROR" ||
if (this._shouldShowConnectionError() ||
(this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline ||
@ -235,7 +241,7 @@ module.exports = React.createClass({
);
}
if (this.state.syncState === "ERROR") {
if (this._shouldShowConnectionError()) {
return null;
}
@ -282,6 +288,21 @@ module.exports = React.createClass({
return avatars;
},
_shouldShowConnectionError: function() {
// no conn bar trumps unread count since you can't get unread messages
// without a connection! (technically may already have some but meh)
// It also trumps the "some not sent" msg since you can't resend without
// a connection!
// There's one situation in which we don't show this 'no connection' bar, and that's
// if it's a resource limit exceeded error: those are shown in the top bar.
const errorIsMauError = Boolean(
this.state.syncStateData &&
this.state.syncStateData.error &&
this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED'
);
return this.state.syncState === "ERROR" && !errorIsMauError;
},
_getUnsentMessageContent: function() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
@ -305,7 +326,43 @@ module.exports = React.createClass({
},
);
} else {
if (
let consentError = null;
let resourceLimitError = null;
for (const m of unsentMessages) {
if (m.error && m.error.errcode === 'M_CONSENT_NOT_GIVEN') {
consentError = m.error;
break;
} else if (m.error && m.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') {
resourceLimitError = m.error;
break;
}
}
if (consentError) {
title = _t(
"You can't send any messages until you review and agree to " +
"<consentLink>our terms and conditions</consentLink>.",
{},
{
'consentLink': (sub) =>
<a href={consentError.data && consentError.data.consent_uri} target="_blank">
{ sub }
</a>,
},
);
} else if (resourceLimitError) {
title = messageForResourceLimitError(
resourceLimitError.data.limit_type,
resourceLimitError.data.admin_contact, {
'monthly_active_user': _td(
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
'': _td(
"Your message wasn't sent because this homeserver has exceeded a resource limit. " +
"Please <a>contact your service administrator</a> to continue using the service.",
),
});
} else if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
@ -329,11 +386,13 @@ module.exports = React.createClass({
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div>
</div>;
},
@ -342,19 +401,17 @@ module.exports = React.createClass({
_getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText');
// no conn bar trumps unread count since you can't get unread messages
// without a connection! (technically may already have some but meh)
// It also trumps the "some not sent" msg since you can't resend without
// a connection!
if (this.state.syncState === "ERROR") {
if (this._shouldShowConnectionError()) {
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
<div>
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ _t('Connectivity to the server has been lost.') }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
</div>
</div>
</div>
);

View File

@ -1,6 +1,7 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,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 = <div className={badgeClasses}>{ FormattingUtils.formatCount(subListNotifCount) }</div>;
badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses}>!</div>;
badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
}
// When collapsed, allow a long hover on the header to show user
// the full tag name and room count
var title;
let title;
if (this.props.collapsed) {
title = this.props.label;
if (roomCount !== '') {
@ -277,63 +325,66 @@ var RoomSubList = React.createClass({
}
}
var incomingCall;
let incomingCall;
if (this.props.incomingCall) {
var self = this;
const self = this;
// Check if the incoming call is for this section
var incomingCallRoom = this.props.list.filter(function(room) {
const incomingCallRoom = this.props.list.filter(function(room) {
return self.props.incomingCall.roomId === room.roomId;
});
if (incomingCallRoom.length === 1) {
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>;
const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall =
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
}
var tabindex = this.props.searchFilter === "" ? "0" : "-1";
const tabindex = this.props.searchFilter === "" ? "0" : "-1";
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}>
{ this.props.collapsed ? '' : this.props.label }
<div className="mx_RoomSubList_roomCount">{ roomCount }</div>
<div className={chevronClasses}></div>
{ badge }
{ incomingCall }
<div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex}>
{this.props.collapsed ? '' : this.props.label}
<div className="mx_RoomSubList_roomCount">{roomCount}</div>
<div className={chevronClasses} />
{badge}
{incomingCall}
</AccessibleButton>
</div>
);
},
_createOverflowTile: function(overflowCount, totalCount) {
var content = <div className="mx_RoomSubList_chevronDown"></div>;
let content = <div className="mx_RoomSubList_chevronDown" />;
var overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
var overflowNotifCount = overflowNotifications[0];
var overflowNotifHighlight = overflowNotifications[1];
const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
const overflowNotifCount = overflowNotifications[0];
const overflowNotifHighlight = overflowNotifications[1];
if (overflowNotifCount && !this.props.collapsed) {
content = FormattingUtils.formatCount(overflowNotifCount);
}
var badgeClasses = classNames({
const badgeClasses = classNames({
'mx_RoomSubList_moreBadge': true,
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
});
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
<div className="mx_RoomSubList_line"></div>
<div className="mx_RoomSubList_more">{ _t("more") }</div>
<div className={ badgeClasses }>{ content }</div>
<div className="mx_RoomSubList_line" />
<div className="mx_RoomSubList_more">{_t("more")}</div>
<div className={badgeClasses}>{content}</div>
</AccessibleButton>
);
},
_showFullMemberList: function() {
this.setState({
truncateAt: -1
truncateAt: -1,
});
this.props.onShowMoreRooms();
@ -341,37 +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 = <TruncatedList className={ classes } truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile} >
{ content }
</TruncatedList>;
}
else {
subList = <TruncatedList className={ classes }>
</TruncatedList>;
subList = <TruncatedList className={classes} truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>
{content}
</TruncatedList>;
} else {
subList = <TruncatedList className={classes}>
</TruncatedList>;
}
const subListContent = <div>
{ this._getHeaderJsx() }
{ subList }
{this._getHeaderJsx()}
{subList}
</div>;
return this.props.editable ?
@ -379,23 +444,26 @@ var RoomSubList = React.createClass({
droppableId={"room-sub-list-droppable_" + this.props.tagName}
type="draggable-RoomTile"
>
{ (provided, snapshot) => (
{(provided, snapshot) => (
<div ref={provided.innerRef}>
{ subListContent }
{subListContent}
</div>
) }
)}
</Droppable> : subListContent;
}
else {
var Loader = sdk.getComponent("elements.Spinner");
} else {
const Loader = sdk.getComponent("elements.Spinner");
if (this.props.showSpinner) {
content = <Loader />;
}
return (
<div className="mx_RoomSubList">
{ this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined }
{ (this.props.showSpinner && !this.state.hidden) ? <Loader /> : undefined }
{this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined}
{ this.state.hidden ? undefined : content }
</div>
);
}
}
},
});
module.exports = RoomSubList;

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