Merge branch 'develop' into hs/purge-irc-hack

This commit is contained in:
Will Hunt 2018-10-03 19:39:14 +01:00 committed by GitHub
commit 17915b5082
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
199 changed files with 11956 additions and 10603 deletions

View file

@ -2,7 +2,6 @@
src/autocomplete/AutocompleteProvider.js src/autocomplete/AutocompleteProvider.js
src/autocomplete/Autocompleter.js src/autocomplete/Autocompleter.js
src/autocomplete/EmojiProvider.js
src/autocomplete/UserProvider.js src/autocomplete/UserProvider.js
src/component-index.js src/component-index.js
src/components/structures/BottomLeftMenu.js src/components/structures/BottomLeftMenu.js
@ -17,7 +16,6 @@ src/components/structures/MessagePanel.js
src/components/structures/NotificationPanel.js src/components/structures/NotificationPanel.js
src/components/structures/RoomDirectory.js src/components/structures/RoomDirectory.js
src/components/structures/RoomStatusBar.js src/components/structures/RoomStatusBar.js
src/components/structures/RoomSubList.js
src/components/structures/RoomView.js src/components/structures/RoomView.js
src/components/structures/ScrollPanel.js src/components/structures/ScrollPanel.js
src/components/structures/SearchBox.js src/components/structures/SearchBox.js
@ -29,7 +27,6 @@ src/components/views/avatars/BaseAvatar.js
src/components/views/avatars/MemberAvatar.js src/components/views/avatars/MemberAvatar.js
src/components/views/create_room/RoomAlias.js src/components/views/create_room/RoomAlias.js
src/components/views/dialogs/ChangelogDialog.js src/components/views/dialogs/ChangelogDialog.js
src/components/views/dialogs/ChatCreateOrReuseDialog.js
src/components/views/dialogs/DeactivateAccountDialog.js src/components/views/dialogs/DeactivateAccountDialog.js
src/components/views/dialogs/SetPasswordDialog.js src/components/views/dialogs/SetPasswordDialog.js
src/components/views/dialogs/UnknownDeviceDialog.js src/components/views/dialogs/UnknownDeviceDialog.js
@ -37,7 +34,6 @@ src/components/views/directory/NetworkDropdown.js
src/components/views/elements/AddressSelector.js src/components/views/elements/AddressSelector.js
src/components/views/elements/DeviceVerifyButtons.js src/components/views/elements/DeviceVerifyButtons.js
src/components/views/elements/DirectorySearchBox.js src/components/views/elements/DirectorySearchBox.js
src/components/views/elements/EditableText.js
src/components/views/elements/ImageView.js src/components/views/elements/ImageView.js
src/components/views/elements/InlineSpinner.js src/components/views/elements/InlineSpinner.js
src/components/views/elements/MemberEventListSummary.js src/components/views/elements/MemberEventListSummary.js
@ -81,7 +77,6 @@ src/components/views/rooms/TopUnreadMessagesBar.js
src/components/views/rooms/UserTile.js src/components/views/rooms/UserTile.js
src/components/views/settings/AddPhoneNumber.js src/components/views/settings/AddPhoneNumber.js
src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangeAvatar.js
src/components/views/settings/ChangeDisplayName.js
src/components/views/settings/ChangePassword.js src/components/views/settings/ChangePassword.js
src/components/views/settings/DevicesPanel.js src/components/views/settings/DevicesPanel.js
src/components/views/settings/IntegrationsManager.js src/components/views/settings/IntegrationsManager.js

View file

@ -95,6 +95,7 @@ module.exports = {
"new-cap": ["warn"], "new-cap": ["warn"],
"key-spacing": ["warn"], "key-spacing": ["warn"],
"prefer-const": ["warn"], "prefer-const": ["warn"],
"arrow-parens": "off",
// crashes currently: https://github.com/eslint/eslint/issues/6274 // crashes currently: https://github.com/eslint/eslint/issues/6274
"generator-star-spacing": "off", "generator-star-spacing": "off",

3
.gitignore vendored
View file

@ -14,3 +14,6 @@ npm-debug.log
/src/component-index.js /src/component-index.js
.DS_Store .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` REACT_SDK_DIR=`pwd`
scripts/fetchdep.sh vector-im riot-web scripts/fetchdep.sh vector-im riot-web
cd "$RIOT_WEB_DIR" pushd "$RIOT_WEB_DIR"
mkdir node_modules mkdir node_modules
npm install 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 rm -r node_modules/matrix-react-sdk
ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk
npm run build
npm run test 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 --travis
popd

View file

@ -15,5 +15,7 @@ addons:
chrome: stable chrome: stable
install: install:
- npm 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: script:
./scripts/travis.sh ./scripts/travis.sh

View file

@ -1,3 +1,542 @@
Changes in [0.13.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.5) (2018-10-01)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.5-rc.1...v0.13.5)
* No changes since rc.1
Changes in [0.13.5-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.5-rc.1) (2018-09-27)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.4...v0.13.5-rc.1)
* resync when LL is toggled, show message when enabled
[\#2178](https://github.com/matrix-org/matrix-react-sdk/pull/2178)
* Update from Weblate.
[\#2179](https://github.com/matrix-org/matrix-react-sdk/pull/2179)
* Split npm start into an init and watch script
[\#2175](https://github.com/matrix-org/matrix-react-sdk/pull/2175)
* show canonical aliases in timeline, and set/remove implicit ones
[\#2171](https://github.com/matrix-org/matrix-react-sdk/pull/2171)
* Fix stale RR and improve LL reliability in RoomView & MemberList.
[\#2168](https://github.com/matrix-org/matrix-react-sdk/pull/2168)
* pass --travis flag to e2e tests to disable tests known not to work Travis CI
[\#2170](https://github.com/matrix-org/matrix-react-sdk/pull/2170)
* Add m.room.aliases to the timeline
[\#2167](https://github.com/matrix-org/matrix-react-sdk/pull/2167)
* postpone loading the members until the user joined the room
[\#2165](https://github.com/matrix-org/matrix-react-sdk/pull/2165)
* Allow translation tags object to be a variable
[\#2166](https://github.com/matrix-org/matrix-react-sdk/pull/2166)
* Don't try to exit fullscreen if not fullscreen
[\#2164](https://github.com/matrix-org/matrix-react-sdk/pull/2164)
* avoid updating the memberlist while the spinner is shown
[\#2161](https://github.com/matrix-org/matrix-react-sdk/pull/2161)
* fix logging room id when LL members fail
[\#2163](https://github.com/matrix-org/matrix-react-sdk/pull/2163)
* dont keep the spinner in the memberlist when fetching /members fails
[\#2162](https://github.com/matrix-org/matrix-react-sdk/pull/2162)
* only dispatch an action for self-membership
[\#2160](https://github.com/matrix-org/matrix-react-sdk/pull/2160)
* avoid unneeded lookups in memberDict
[\#2153](https://github.com/matrix-org/matrix-react-sdk/pull/2153)
* Update from Weblate.
[\#2157](https://github.com/matrix-org/matrix-react-sdk/pull/2157)
* avoid memberlist refresh for events related to rooms other but the current
[\#2156](https://github.com/matrix-org/matrix-react-sdk/pull/2156)
Changes in [0.13.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.4) (2018-09-10)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.4-rc.1...v0.13.4)
* No changes since rc.1
Changes in [0.13.4-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.4-rc.1) (2018-09-07)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.3...v0.13.4-rc.1)
* Error on splash screen if sync is failing
[\#2155](https://github.com/matrix-org/matrix-react-sdk/pull/2155)
* Do full registration if HS doesn't support ILAG
[\#2150](https://github.com/matrix-org/matrix-react-sdk/pull/2150)
* Re-apply "Don't rely on room members to query power levels"
[\#2152](https://github.com/matrix-org/matrix-react-sdk/pull/2152)
* s/DidMount/WillMount/ in MessageComposerInput
[\#2151](https://github.com/matrix-org/matrix-react-sdk/pull/2151)
* Revert "Don't rely on room members to query power levels"
[\#2149](https://github.com/matrix-org/matrix-react-sdk/pull/2149)
* Don't rely on room members to query power levels
[\#2145](https://github.com/matrix-org/matrix-react-sdk/pull/2145)
* Correctly mark email as optional
[\#2148](https://github.com/matrix-org/matrix-react-sdk/pull/2148)
* guests trying to join communities should fire the ILAG flow.
[\#2059](https://github.com/matrix-org/matrix-react-sdk/pull/2059)
* Fix DM avatars, part 3
[\#2146](https://github.com/matrix-org/matrix-react-sdk/pull/2146)
* Fix: show spinner again while recovering from connection error
[\#2143](https://github.com/matrix-org/matrix-react-sdk/pull/2143)
* Fix: infinite spinner on trying to create welcomeUserId room without consent
[\#2147](https://github.com/matrix-org/matrix-react-sdk/pull/2147)
* Show spinner in member list while loading members
[\#2139](https://github.com/matrix-org/matrix-react-sdk/pull/2139)
* Slash command to discard megolm session
[\#2140](https://github.com/matrix-org/matrix-react-sdk/pull/2140)
Changes in [0.13.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.3) (2018-09-03)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.3-rc.2...v0.13.3)
* No changes since rc.2
Changes in [0.13.3-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.3-rc.2) (2018-08-31)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.3-rc.1...v0.13.3-rc.2)
* Update js-sdk to fix exception
Changes in [0.13.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.13.3-rc.1) (2018-08-30)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.13.2...v0.13.3-rc.1)
* Fix DM avatar
[\#2141](https://github.com/matrix-org/matrix-react-sdk/pull/2141)
* Update from Weblate.
[\#2142](https://github.com/matrix-org/matrix-react-sdk/pull/2142)
* Support m.room.tombstone events
[\#2124](https://github.com/matrix-org/matrix-react-sdk/pull/2124)
* Support room creation events
[\#2123](https://github.com/matrix-org/matrix-react-sdk/pull/2123)
* Support for room upgrades
[\#2122](https://github.com/matrix-org/matrix-react-sdk/pull/2122)
* Fix: dont show 1:1 avatar for rooms +2 members but only <=2 members loaded
[\#2137](https://github.com/matrix-org/matrix-react-sdk/pull/2137)
* Render terms & conditions in settings
[\#2136](https://github.com/matrix-org/matrix-react-sdk/pull/2136)
* Don't crash if the value of a room tag is null
[\#2133](https://github.com/matrix-org/matrix-react-sdk/pull/2133)
* Add stub for getVisibleRooms()
[\#2134](https://github.com/matrix-org/matrix-react-sdk/pull/2134)
* Fix LL crash trying to render own avatar in composer when member isn't
available yet
[\#2132](https://github.com/matrix-org/matrix-react-sdk/pull/2132)
* Support M_INCOMPATIBLE_ROOM_VERSION
[\#2125](https://github.com/matrix-org/matrix-react-sdk/pull/2125)
* Hide replaced rooms
[\#2127](https://github.com/matrix-org/matrix-react-sdk/pull/2127)
* Fix CPU spin on joining large room
[\#2128](https://github.com/matrix-org/matrix-react-sdk/pull/2128)
* Change format of server usage limit message
[\#2131](https://github.com/matrix-org/matrix-react-sdk/pull/2131)
* Re-apply "Fix showing peek preview while LL members are loading""
[\#2130](https://github.com/matrix-org/matrix-react-sdk/pull/2130)
* Revert "Fix showing peek preview while LL members are loading"
[\#2129](https://github.com/matrix-org/matrix-react-sdk/pull/2129)
* Fix showing peek preview while LL members are loading
[\#2126](https://github.com/matrix-org/matrix-react-sdk/pull/2126)
* Destroy non-persistent widgets when switching room
[\#2098](https://github.com/matrix-org/matrix-react-sdk/pull/2098)
* Lazy loading of room members
[\#2118](https://github.com/matrix-org/matrix-react-sdk/pull/2118)
* Lazy loading: feature toggle
[\#2115](https://github.com/matrix-org/matrix-react-sdk/pull/2115)
* Lazy loading: cleanup
[\#2116](https://github.com/matrix-org/matrix-react-sdk/pull/2116)
* Lazy loading: fix end-to-end encryption rooms
[\#2113](https://github.com/matrix-org/matrix-react-sdk/pull/2113)
* Lazy loading: Lazy load members while backpaginating
[\#2104](https://github.com/matrix-org/matrix-react-sdk/pull/2104)
* Lazy loading: don't assume we have our own member available
[\#2102](https://github.com/matrix-org/matrix-react-sdk/pull/2102)
* Lazy load room members - Part I
[\#2072](https://github.com/matrix-org/matrix-react-sdk/pull/2072)
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) Changes in [0.12.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.7) (2018-06-12)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.7-rc.1...v0.12.7) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.7-rc.1...v0.12.7)
@ -365,7 +904,7 @@ Changes in [0.12.0-rc.7](https://github.com/matrix-org/matrix-react-sdk/releases
[\#1816](https://github.com/matrix-org/matrix-react-sdk/pull/1816) [\#1816](https://github.com/matrix-org/matrix-react-sdk/pull/1816)
* Improve group joining/leaving feedback * Improve group joining/leaving feedback
[\#1831](https://github.com/matrix-org/matrix-react-sdk/pull/1831) [\#1831](https://github.com/matrix-org/matrix-react-sdk/pull/1831)
Changes in [0.12.0-rc.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.6) (2018-04-09) Changes in [0.12.0-rc.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.6) (2018-04-09)
=============================================================================================================== ===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.5...v0.12.0-rc.6) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.5...v0.12.0-rc.6)

View file

@ -11,16 +11,8 @@ a 'skin'. A skin provides:
* The containing application * The containing application
* Zero or more 'modules' containing non-UI functionality * Zero or more 'modules' containing non-UI functionality
**WARNING: As of July 2016, the skinning abstraction is broken due to rapid As of Aug 2018, the only skin that exists is `vector-im/riot-web`; it and
development of `matrix-react-sdk` to meet the needs of Riot (codenamed Vector), the first app `matrix-org/matrix-react-sdk` should effectively
to be built on top of the SDK** (https://github.com/vector-im/riot-web).
Right now `matrix-react-sdk` depends on some functionality from `riot-web`
(e.g. CSS), and `matrix-react-sdk` contains some Riot specific behaviour
(grep for 'vector'). This layering will be fixed asap once Riot development
has stabilised, but for now we do not advise trying to create new skins for
matrix-react-sdk until the layers are clearly separated again.
In the interim, `vector-im/riot-web` and `matrix-org/matrix-react-sdk` should
be considered as a single project (for instance, matrix-react-sdk bugs be considered as a single project (for instance, matrix-react-sdk bugs
are currently filed against vector-im/riot-web rather than this project). are currently filed against vector-im/riot-web rather than this project).
@ -48,15 +40,14 @@ https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst
Please follow the Matrix JS/React code style as per: Please follow the Matrix JS/React code style as per:
https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md
Whilst the layering separation between matrix-react-sdk and Riot is broken Code should be committed as follows:
(as of July 2016), code should be committed as follows:
* All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components * All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components
* Riot-specific components: https://github.com/vector-im/riot-web/tree/master/src/components * Riot-specific components: https://github.com/vector-im/riot-web/tree/master/src/components
* In practice, `matrix-react-sdk` is still evolving so fast that the maintenance * In practice, `matrix-react-sdk` is still evolving so fast that the maintenance
burden of customising and overriding these components for Riot can seriously burden of customising and overriding these components for Riot can seriously
impede development. So right now, there should be very few (if any) customisations for Riot. impede development. So right now, there should be very few (if any) customisations for Riot.
* CSS for Matrix SDK components: https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/matrix-react-sdk * CSS: https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/matrix-react-sdk
* CSS for Riot-specific overrides and components: https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/riot-web * Theme specific CSS & resources: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes
React components in matrix-react-sdk are come in two different flavours: React components in matrix-react-sdk are come in two different flavours:
'structures' and 'views'. Structures are stateful components which handle the 'structures' and 'views'. Structures are stateful components which handle the
@ -84,6 +75,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold:
* Per-view CSS is optional - it could choose to inherit all its styling from * Per-view CSS is optional - it could choose to inherit all its styling from
the context of the rest of the app, although this is unusual for any but the context of the rest of the app, although this is unusual for any but
* Theme specific CSS & resources: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes
structural components (lacking presentation logic) and the simplest view structural components (lacking presentation logic) and the simplest view
components. components.
@ -139,8 +131,7 @@ for now.
OUTDATED: To Create Your Own Skin OUTDATED: To Create Your Own Skin
================================= =================================
**This is ALL LIES currently, as skinning is currently broken - see the WARNING **This is ALL LIES currently, and needs to be updated**
section at the top of this readme.**
Skins are modules are exported from such a package in the `lib` directory. Skins are modules are exported from such a package in the `lib` directory.
`lib/skins` contains one directory per-skin, named after the skin, and the `lib/skins` contains one directory per-skin, named after the skin, and the

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.

6729
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.12.7", "version": "0.13.5",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -38,10 +38,12 @@
"reskindex:watch": "node scripts/reskindex.js -h header -w", "reskindex:watch": "node scripts/reskindex.js -h header -w",
"i18n": "matrix-gen-i18n", "i18n": "matrix-gen-i18n",
"prunei18n": "matrix-prune-i18n", "prunei18n": "matrix-prune-i18n",
"build": "npm run reskindex && babel src -d lib --source-maps --copy-files", "build": "npm run reskindex && npm run start:init",
"build:watch": "babel src -w -d lib --source-maps --copy-files", "build:watch": "babel src -w --skip-initial-build -d lib --source-maps --copy-files",
"emoji-data-strip": "node scripts/emoji-data-strip.js", "emoji-data-strip": "node scripts/emoji-data-strip.js",
"start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"", "start": "npm run start:init && npm run start:all",
"start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"npm run build:watch\" \"npm run reskindex:watch\"",
"start:init": "babel src -d lib --source-maps --copy-files",
"lint": "eslint src/", "lint": "eslint src/",
"lintall": "eslint src/ test/", "lintall": "eslint src/ test/",
"lintwithexclusions": "eslint --max-warnings 20 --ignore-path .eslintignore.errorfiles src test", "lintwithexclusions": "eslint --max-warnings 20 --ignore-path .eslintignore.errorfiles src test",
@ -59,9 +61,6 @@
"classnames": "^2.1.2", "classnames": "^2.1.2",
"commonmark": "^0.28.1", "commonmark": "^0.28.1",
"counterpart": "^0.18.0", "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", "emojione": "2.2.7",
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"filesize": "3.5.6", "filesize": "3.5.6",
@ -73,10 +72,10 @@
"glob": "^5.0.14", "glob": "^5.0.14",
"highlight.js": "^9.0.0", "highlight.js": "^9.0.0",
"isomorphic-fetch": "^2.2.1", "isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3", "linkifyjs": "^2.1.6",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"lolex": "2.3.2", "lolex": "2.3.2",
"matrix-js-sdk": "0.10.4", "matrix-js-sdk": "0.11.1",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"pako": "^1.0.5", "pako": "^1.0.5",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
@ -87,11 +86,16 @@
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^15.6.0", "react-dom": "^15.6.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"sanitize-html": "^1.14.1", "resize-observer-polyfill": "^1.5.0",
"sanitize-html": "^1.18.4",
"slate": "0.34.7",
"slate-html-serializer": "^0.6.1",
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",
"slate-react": "^0.12.4",
"text-encoding-utf-8": "^1.0.1", "text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0", "url": "^0.11.0",
"velocity-vector": "vector-im/velocity#059e3b2", "velocity-vector": "vector-im/velocity#059e3b2",
"whatwg-fetch": "^1.0.0" "whatwg-fetch": "^1.1.1"
}, },
"devDependencies": { "devDependencies": {
"babel-cli": "^6.5.2", "babel-cli": "^6.5.2",
@ -109,6 +113,7 @@
"babel-preset-es2017": "^6.14.0", "babel-preset-es2017": "^6.14.0",
"babel-preset-react": "^6.11.1", "babel-preset-react": "^6.11.1",
"chokidar": "^1.6.1", "chokidar": "^1.6.1",
"concurrently": "^4.0.1",
"eslint": "^3.13.1", "eslint": "^3.13.1",
"eslint-config-google": "^0.7.1", "eslint-config-google": "^0.7.1",
"eslint-plugin-babel": "^4.0.1", "eslint-plugin-babel": "^4.0.1",
@ -118,20 +123,19 @@
"expect": "^1.16.0", "expect": "^1.16.0",
"flow-parser": "^0.57.3", "flow-parser": "^0.57.3",
"json-loader": "^0.5.3", "json-loader": "^0.5.3",
"karma": "^1.7.0", "karma": "^3.0.0",
"karma-chrome-launcher": "^0.2.3", "karma-chrome-launcher": "^0.2.3",
"karma-cli": "^0.1.2", "karma-cli": "^0.1.2",
"karma-junit-reporter": "^0.4.1", "karma-junit-reporter": "^1.2.0",
"karma-logcapture-reporter": "0.0.1", "karma-logcapture-reporter": "0.0.1",
"karma-mocha": "^0.2.2", "karma-mocha": "^0.2.2",
"karma-sourcemap-loader": "^0.3.7", "karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "^0.0.31", "karma-spec-reporter": "^0.0.31",
"karma-summary-reporter": "^1.3.3", "karma-summary-reporter": "^1.3.3",
"karma-webpack": "^1.7.0", "karma-webpack": "^3.0.5",
"matrix-mock-request": "^1.2.1", "matrix-mock-request": "^1.2.1",
"matrix-react-test-utils": "^0.1.1", "matrix-react-test-utils": "^0.1.1",
"mocha": "^5.0.5", "mocha": "^5.0.5",
"parallelshell": "^3.0.2",
"react-addons-test-utils": "^15.4.0", "react-addons-test-utils": "^15.4.0",
"require-json": "0.0.1", "require-json": "0.0.1",
"rimraf": "^2.4.3", "rimraf": "^2.4.3",

View file

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

View file

@ -39,6 +39,7 @@
@import "./views/dialogs/_EncryptedEventDialog.scss"; @import "./views/dialogs/_EncryptedEventDialog.scss";
@import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_GroupAddressPicker.scss";
@import "./views/dialogs/_QuestionDialog.scss"; @import "./views/dialogs/_QuestionDialog.scss";
@import "./views/dialogs/_RoomUpgradeDialog.scss";
@import "./views/dialogs/_SetEmailDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss";
@import "./views/dialogs/_SetMxIdDialog.scss"; @import "./views/dialogs/_SetMxIdDialog.scss";
@import "./views/dialogs/_SetPasswordDialog.scss"; @import "./views/dialogs/_SetPasswordDialog.scss";
@ -67,6 +68,7 @@
@import "./views/groups/_GroupUserSettings.scss"; @import "./views/groups/_GroupUserSettings.scss";
@import "./views/login/_InteractiveAuthEntryComponents.scss"; @import "./views/login/_InteractiveAuthEntryComponents.scss";
@import "./views/login/_ServerConfig.scss"; @import "./views/login/_ServerConfig.scss";
@import "./views/messages/_CreateEvent.scss";
@import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_DateSeparator.scss";
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@ -99,6 +101,7 @@
@import "./views/rooms/_RoomSettings.scss"; @import "./views/rooms/_RoomSettings.scss";
@import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTile.scss";
@import "./views/rooms/_RoomTooltip.scss"; @import "./views/rooms/_RoomTooltip.scss";
@import "./views/rooms/_RoomUpgradeWarningBar.scss";
@import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SearchBar.scss";
@import "./views/rooms/_SearchableEntityList.scss"; @import "./views/rooms/_SearchableEntityList.scss";
@import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_Stickers.scss";

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -54,6 +55,10 @@ limitations under the License.
} }
.mx_LeftPanel .mx_AppTile_mini {
height: 132px;
}
.mx_LeftPanel .mx_RoomList_scrollbar { .mx_LeftPanel .mx_RoomList_scrollbar {
order: 1; order: 1;

View file

@ -56,6 +56,18 @@ limitations under the License.
flex: 1; flex: 1;
} }
.mx_MatrixChat_syncError {
color: $accent-fg-color;
background-color: $warning-bg-color;
border-radius: 5px;
display: table;
padding: 30px;
position: absolute;
top: 100px;
left: 50%;
transform: translateX(-50%);
}
.mx_MatrixChat .mx_LeftPanel { .mx_MatrixChat .mx_LeftPanel {
order: 1; order: 1;

View file

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

View file

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

View file

@ -176,10 +176,7 @@ hr.mx_RoomView_myReadMarker {
z-index: 1000; z-index: 1000;
overflow: hidden; overflow: hidden;
-webkit-transition: all .2s ease-out; transition: all .2s ease-out;
-moz-transition: all .2s ease-out;
-ms-transition: all .2s ease-out;
-o-transition: all .2s ease-out;
} }
.mx_RoomView_statusArea_expanded { .mx_RoomView_statusArea_expanded {

View file

@ -0,0 +1,19 @@
/*
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_RoomUpgradeDialog {
padding-right: 70px;
}

View file

@ -27,6 +27,10 @@
padding-right: 5px; 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_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me,
.mx_EventTile_content .mx_AtRoomPill, .mx_EventTile_content .mx_AtRoomPill,
.mx_MessageComposer_input .mx_AtRoomPill { .mx_MessageComposer_input .mx_AtRoomPill {

View file

@ -28,6 +28,18 @@ limitations under the License.
margin-top: -2px; 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 { .mx_MatrixToolbar_content {
flex: 1; flex: 1;
} }
@ -59,4 +71,4 @@ limitations under the License.
.mx_MatrixToolbar_changelog { .mx_MatrixToolbar_changelog {
white-space: pre; white-space: pre;
} }

View file

@ -0,0 +1,37 @@
/*
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_CreateEvent {
background-color: $info-plinth-bg-color;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
padding-bottom: 10px;
}
.mx_CreateEvent_image {
float: left;
padding-right: 20px;
width: 72px;
height: 34px;
}
.mx_CreateEvent_header {
font-weight: bold;
}
.mx_CreateEvent_link {
}

View file

@ -75,6 +75,22 @@ limitations under the License.
border-radius: 2px; 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 { .mx_AppTileMenuBar {
margin: 0; margin: 0;
padding: 2px 10px; padding: 2px 10px;
@ -126,6 +142,18 @@ limitations under the License.
overflow: hidden; overflow: hidden;
} }
.mx_AppTileBody_mini {
height: 112px;
width: 100%;
overflow: hidden;
}
.mx_AppTileBody_mini iframe {
border: none;
width: 100%;
height: 100%;
}
.mx_AppTileBody iframe { .mx_AppTileBody iframe {
width: 100%; width: 100%;
height: 280px; height: 280px;

View file

@ -69,7 +69,8 @@
flex-flow: wrap; flex-flow: wrap;
} }
.mx_Autocomplete_Completion.selected { .mx_Autocomplete_Completion.selected,
.mx_Autocomplete_Completion:hover {
background: $menu-bg-color; background: $menu-bg-color;
outline: none; outline: none;
} }

View file

@ -31,7 +31,6 @@ limitations under the License.
top: 14px; top: 14px;
left: 8px; left: 8px;
cursor: pointer; cursor: pointer;
z-index: 2;
} }
.mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { .mx_EventTile.mx_EventTile_info .mx_EventTile_avatar {
@ -187,7 +186,6 @@ limitations under the License.
.mx_EventTile_msgOption { .mx_EventTile_msgOption {
float: right; float: right;
text-align: right; text-align: right;
z-index: 1;
position: relative; position: relative;
width: 90px; width: 90px;
@ -290,7 +288,6 @@ limitations under the License.
position: absolute; position: absolute;
top: 9px; top: 9px;
left: 46px; left: 46px;
z-index: 2;
cursor: pointer; cursor: pointer;
} }
@ -392,7 +389,6 @@ limitations under the License.
overflow-x: overlay; overflow-x: overlay;
overflow-y: visible; overflow-y: visible;
max-height: 30vh; max-height: 30vh;
position: static;
} }
.mx_EventTile_content .markdown-body code { .mx_EventTile_content .markdown-body code {
@ -401,20 +397,25 @@ limitations under the License.
color: #333; color: #333;
} }
.mx_EventTile_pre_container {
// For correct positioning of _copyButton (See TextualBody)
position: relative;
}
// Inserted adjacent to <pre> blocks, (See TextualBody)
.mx_EventTile_copyButton { .mx_EventTile_copyButton {
position: absolute; position: absolute;
display: inline-block; display: inline-block;
visibility: hidden; visibility: hidden;
cursor: pointer; cursor: pointer;
top: 6px; top: 6px;
right: 36px; right: 6px;
width: 19px; width: 19px;
height: 19px; height: 19px;
background-image: url($copy-button-url); background-image: url($copy-button-url);
} }
.mx_EventTile_body pre { .mx_EventTile_body pre {
position: relative;
border: 1px solid transparent; border: 1px solid transparent;
} }
@ -423,7 +424,7 @@ limitations under the License.
border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter
} }
.mx_EventTile_body pre:hover .mx_EventTile_copyButton .mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton
{ {
visibility: visible; visibility: visible;
} }
@ -445,6 +446,7 @@ limitations under the License.
.mx_EventTile_content .markdown-body h2 .mx_EventTile_content .markdown-body h2
{ {
font-size: 1.5em; font-size: 1.5em;
border-bottom: none ! important; // override GFM
} }
.mx_EventTile_content .markdown-body a { .mx_EventTile_content .markdown-body a {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -22,6 +23,29 @@ limitations under the License.
position: relative; position: relative;
} }
.mx_MessageComposer_replaced_wrapper {
margin-left: auto;
margin-right: auto;
}
.mx_MessageComposer_replaced_valign {
height: 60px;
display: table-cell;
vertical-align: middle;
}
.mx_MessageComposer_roomReplaced_icon {
float: left;
margin-right: 20px;
margin-top: 5px;
width: 31px;
height: 31px;
}
.mx_MessageComposer_roomReplaced_header {
font-weight: bold;
}
.mx_MessageComposer_autocomplete_wrapper { .mx_MessageComposer_autocomplete_wrapper {
position: relative; position: relative;
height: 0; height: 0;
@ -70,6 +94,7 @@ limitations under the License.
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
cursor: text;
} }
.mx_MessageComposer_input { .mx_MessageComposer_input {
@ -78,12 +103,29 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 60px; min-height: 60px;
justify-content: center; justify-content: start;
align-items: flex-start; align-items: flex-start;
font-size: 14px; font-size: 14px;
margin-right: 6px; 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 @keyframes visualbell
{ {
from { background-color: #faa } from { background-color: #faa }
@ -94,28 +136,6 @@ limitations under the License.
animation: 0.2s visualbell; 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 { .mx_MessageComposer_input blockquote {
color: $blockquote-fg-color; color: $blockquote-fg-color;
margin: 0 0 16px; margin: 0 0 16px;
@ -123,7 +143,7 @@ limitations under the License.
border-left: 4px solid $blockquote-bar-color; 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; background-color: $rte-code-bg-color;
border-radius: 3px; border-radius: 3px;
padding: 10px; padding: 10px;

View file

@ -20,6 +20,7 @@ limitations under the License.
margin-bottom: 20px; margin-bottom: 20px;
} }
.mx_RoomSettings_upgradeButton,
.mx_RoomSettings_leaveButton, .mx_RoomSettings_leaveButton,
.mx_RoomSettings_unbanButton { .mx_RoomSettings_unbanButton {
@mixin mx_DialogButton; @mixin mx_DialogButton;
@ -27,11 +28,16 @@ limitations under the License.
margin-right: 8px; margin-right: 8px;
} }
.mx_RoomSettings_upgradeButton,
.mx_RoomSettings_leaveButton:hover, .mx_RoomSettings_leaveButton:hover,
.mx_RoomSettings_unbanButton:hover { .mx_RoomSettings_unbanButton:hover {
@mixin mx_DialogButton_hover; @mixin mx_DialogButton_hover;
} }
.mx_RoomSettings_upgradeButton.danger {
@mixin mx_DialogButton_danger;
}
.mx_RoomSettings_integrationsButton_error { .mx_RoomSettings_integrationsButton_error {
position: relative; position: relative;
cursor: not-allowed; cursor: not-allowed;

View file

@ -0,0 +1,48 @@
/*
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_RoomUpgradeWarningBar {
text-align: center;
height: 176px;
background-color: $event-selected-color;
align-items: center;
flex-direction: column;
justify-content: center;
display: flex;
background-color: $preview-bar-bg-color;
-webkit-align-items: center;
padding-left: 20px;
padding-right: 20px;
}
.mx_RoomUpgradeWarningBar_header {
color: $warning-color;
font-weight: bold;
}
.mx_RoomUpgradeWarningBar_body {
color: $warning-color;
}
.mx_RoomUpgradeWarningBar_upgradelink {
color: $warning-color;
text-decoration: underline;
}
.mx_RoomUpgradeWarningBar_small {
color: $greyed-fg-color;
font-size: 70%;
}

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 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 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 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,6 @@
<svg width="72" height="34" viewBox="0 0 72 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 7.26087V1H28.7889V7.26087M1 7.26087V33H28.7889V7.26087M1 7.26087H28.7889M4.16583 4.13043H16.8291" stroke="#454545" stroke-width="2" stroke-linejoin="round"/>
<path d="M43.2109 7.26087V1H70.9999V7.26087M43.2109 7.26087V33H70.9999V7.26087M43.2109 7.26087H70.9999M46.3768 4.13043H59.0401" stroke="#454545" stroke-width="2" stroke-linejoin="round"/>
<path d="M27.03 28.8262C34.2226 28.8262 36.0207 26.343 36.0207 25.1014V16.0996C36.0207 12.1264 43.6283 11.3401 47.432 11.4436" stroke="black" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 623 B

13
res/img/room_replaced.svg Normal file
View file

@ -0,0 +1,13 @@
<svg width="31" height="31" viewBox="0 0 31 31" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="31" height="31" fill="black" fill-opacity="0"/>
<circle cx="15.5" cy="15.5" r="15.5" fill="#A2A2A2"/>
<path d="M22.8553 15.5C22.8553 19.5622 19.5622 22.8553 15.5 22.8553C11.4378 22.8553 8.14474 19.5622 8.14474 15.5C8.14474 11.4378 11.4378 8.14474 15.5 8.14474C19.5622 8.14474 22.8553 11.4378 22.8553 15.5ZM15.5 24.25C20.3325 24.25 24.25 20.3325 24.25 15.5C24.25 10.6675 20.3325 6.75 15.5 6.75C10.6675 6.75 6.75 10.6675 6.75 15.5C6.75 20.3325 10.6675 24.25 15.5 24.25Z" fill="white" stroke="white" stroke-width="0.5"/>
<rect x="16.2666" y="30.5032" width="1.5" height="29.4046" transform="rotate(179.987 16.2666 30.5032)" fill="#A2A2A2"/>
<rect x="8.89404" y="28.8434" width="1.5" height="29.6593" transform="rotate(-149.607 8.89404 28.8434)" fill="#A2A2A2"/>
<rect x="2.87988" y="24.495" width="1.5" height="30.0747" transform="rotate(-121.597 2.87988 24.495)" fill="#A2A2A2"/>
<rect x="2.16284" y="23.3413" width="1.5" height="29.6434" transform="rotate(-116.581 2.16284 23.3413)" fill="#A2A2A2"/>
<rect x="1.55176" y="22.1343" width="1.5" height="29.5016" transform="rotate(-111.584 1.55176 22.1343)" fill="#A2A2A2"/>
<path d="M9.5 17L7.5 20L5.5 17L9.5 17Z" fill="white" stroke="white"/>
<path d="M21.5 15L23.5 12L25.5 15H21.5Z" fill="white" stroke="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

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

View file

@ -25,6 +25,9 @@ $focus-brightness: 125%;
// red warning colour // red warning colour
$warning-color: #ff0064; $warning-color: #ff0064;
// background colour for warnings
$warning-bg-color: #DF2A8B;
$info-bg-color: #2A9EDF;
$mention-user-pill-bg-color: #ff0064; $mention-user-pill-bg-color: #ff0064;
$other-user-pill-bg-color: rgba(0, 0, 0, 0.1); $other-user-pill-bg-color: rgba(0, 0, 0, 0.1);
@ -168,6 +171,10 @@ $progressbar-color: #000;
outline: none; outline: none;
} }
@define-mixin mx_DialogButton_danger {
background-color: $warning-color;
}
@define-mixin mx_DialogButton_hover { @define-mixin mx_DialogButton_hover {
} }

View file

@ -12,6 +12,9 @@ const output = Object.keys(EMOJI_DATA).map(
category: datum.category, category: datum.category,
emoji_order: datum.emoji_order, emoji_order: datum.emoji_order,
}; };
if (datum.aliases.length > 0) {
newDatum.aliases = datum.aliases;
}
if (datum.aliases_ascii.length > 0) { if (datum.aliases_ascii.length > 0) {
newDatum.aliases_ascii = datum.aliases_ascii; newDatum.aliases_ascii = datum.aliases_ascii;
} }

View file

@ -143,7 +143,7 @@ function getTranslationsJs(file) {
// Validate tag replacements // Validate tag replacements
if (node.arguments.length > 2) { if (node.arguments.length > 2) {
const tagMap = node.arguments[2]; const tagMap = node.arguments[2];
for (const prop of tagMap.properties) { for (const prop of tagMap.properties || []) {
if (prop.key.type === 'Literal') { if (prop.key.type === 'Literal') {
const tag = prop.key.value; const tag = prop.key.value;
// RegExp same as in src/languageHandler.js // RegExp same as in src/languageHandler.js
@ -158,6 +158,7 @@ function getTranslationsJs(file) {
} catch (e) { } catch (e) {
console.log(); console.log();
console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`); console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`);
console.error(e);
process.exit(1); process.exit(1);
} }
} }

View file

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

View file

@ -59,8 +59,11 @@ import sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import dis from './dispatcher'; import dis from './dispatcher';
import SdkConfig from './SdkConfig';
import { showUnknownDeviceDialogForCalls } from './cryptodevices'; import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import SettingsStore from "./settings/SettingsStore"; import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import ScalarAuthClient from './ScalarAuthClient';
global.mxCalls = { global.mxCalls = {
//room_id: MatrixCall //room_id: MatrixCall
@ -297,67 +300,7 @@ function _onAction(payload) {
break; break;
case 'place_conference_call': case 'place_conference_call':
console.log("Place conference call in %s", payload.room_id); console.log("Place conference call in %s", payload.room_id);
_startCallApp(payload.room_id, payload.type);
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'),
});
return;
}
if (SettingsStore.isFeatureEnabled('feature_jitsi')) {
_startCallApp(payload.room_id, payload.type);
} else {
if (!ConferenceHandler) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, {
description: _t('Conference calls are not supported in this client'),
});
} else if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
title: _t('VoIP is unsupported'),
description: _t('You cannot place VoIP calls in this browser.'),
});
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, {
title: _t('Warning!'),
description: _t('Conference calling is in development and may not be reliable.'),
onFinished: (confirm)=>{
if (confirm) {
ConferenceHandler.createNewMatrixCall(
MatrixClientPeg.get(), payload.room_id,
).done(function(call) {
placeCall(call);
}, function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Conference call failed: " + err);
Modal.createTrackedDialog(
'Call Handler',
'Failed to set up conference call',
ErrorDialog,
{
title: _t('Failed to set up conference call'),
description: (
_t('Conference call failed.') +
' ' + ((err && err.message) ? err.message : '')
),
},
);
});
}
},
});
}
}
break; break;
case 'incoming_call': case 'incoming_call':
{ {
@ -400,27 +343,61 @@ function _onAction(payload) {
} }
} }
function _startCallApp(roomId, type) { 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({ dis.dispatch({
action: 'appsDrawer', action: 'appsDrawer',
show: true, show: true,
}); });
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
if (!room) { const currentRoomWidgets = WidgetUtils.getRoomWidgets(room);
console.error("Attempted to start conference call widget in unknown room: " + roomId);
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; return;
} }
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); const currentJitsiWidgets = currentRoomWidgets.filter((ev) => {
const currentJitsiWidgets = appsStateEvents.filter((ev) => { return ev.getContent().type === 'jitsi';
ev.getContent().type == 'jitsi';
}); });
if (currentJitsiWidgets.length > 0) { if (currentJitsiWidgets.length > 0) {
console.warn( console.warn(
"Refusing to start conference call widget in " + roomId + "Refusing to start conference call widget in " + roomId +
" a conference call widget is already present", " 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; return;
} }
@ -437,31 +414,38 @@ function _startCallApp(roomId, type) {
'avatarUrl=$matrix_avatar_url', 'avatarUrl=$matrix_avatar_url',
'email=$matrix_user_id', 'email=$matrix_user_id',
].join('&'); ].join('&');
const widgetUrl = (
'https://scalar.vector.im/api/widgets' +
'/jitsi.html?' +
queryString
);
const jitsiEvent = { let widgetUrl;
type: 'jitsi', if (SdkConfig.get().integrations_jitsi_widget_url) {
url: widgetUrl, // Try this config key. This probably isn't ideal as a way of discovering this
data: { // URL, but this will at least allow the integration manager to not be hardcoded.
widgetSessionId: widgetSessionId, widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString;
}, } else {
}; widgetUrl = SdkConfig.get().integrations_rest_url + '/widgets/jitsi.html?' + queryString;
}
const widgetData = { widgetSessionId };
const widgetId = ( const widgetId = (
'jitsi_' + 'jitsi_' +
MatrixClientPeg.get().credentials.userId + MatrixClientPeg.get().credentials.userId +
'_' + '_' +
Date.now() Date.now()
); );
MatrixClientPeg.get().sendStateEvent(
roomId, WidgetUtils.setRoomWidget(roomId, widgetId, 'jitsi', widgetUrl, 'Jitsi', widgetData).then(() => {
'im.vector.modular.widgets', console.log('Jitsi widget added');
jitsiEvent, }).catch((e) => {
widgetId, if (e.errcode === 'M_FORBIDDEN') {
).then(() => console.log('Sent jitsi widget state event'), (e) => console.error(e)); 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 // FIXME: Nasty way of making sure we only register
@ -498,6 +482,24 @@ const callHandler = {
return null; 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) { setConferenceHandler: function(confHandler) {
ConferenceHandler = confHandler; ConferenceHandler = confHandler;
}, },

View file

@ -15,46 +15,44 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ContentState, convertToRaw, convertFromRaw} from 'draft-js'; import { Value } from 'slate';
import * as RichText from './RichText';
import Markdown from './Markdown';
import _clamp from 'lodash/clamp'; import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown'; type MessageFormat = 'rich' | 'markdown';
class HistoryItem { class HistoryItem {
// Keeping message for backwards-compatibility // We store history items in their native format to ensure history is accurate
message: string; // and then convert them if our RTE has subsequently changed format.
rawContentState: RawDraftContentState; value: Value;
format: MessageFormat = 'html'; format: MessageFormat = 'rich';
constructor(contentState: ?ContentState, format: ?MessageFormat) { constructor(value: ?Value, format: ?MessageFormat) {
this.rawContentState = contentState ? convertToRaw(contentState) : null; this.value = value;
this.format = format; this.format = format;
} }
toContentState(outputFormat: MessageFormat): ContentState { static fromJSON(obj: Object): HistoryItem {
const contentState = convertFromRaw(this.rawContentState); return new HistoryItem(
if (outputFormat === 'markdown') { Value.fromJSON(obj.value),
if (this.format === 'html') { obj.format,
return ContentState.createFromText(RichText.stateToMarkdown(contentState)); );
} }
} else {
if (this.format === 'markdown') { toJSON(): Object {
return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML()); return {
} value: this.value.toJSON(),
} format: this.format,
// history item has format === outputFormat };
return contentState;
} }
} }
export default class ComposerHistoryManager { export default class ComposerHistoryManager {
history: Array<HistoryItem> = []; history: Array<HistoryItem> = [];
prefix: string; prefix: string;
lastIndex: number = 0; lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') { constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId; this.prefix = prefix + roomId;
@ -62,23 +60,28 @@ export default class ComposerHistoryManager {
// TODO: Performance issues? // TODO: Performance issues?
let item; let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
this.history.push( try {
Object.assign(new HistoryItem(), JSON.parse(item)), this.history.push(
); HistoryItem.fromJSON(JSON.parse(item)),
);
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
} }
this.lastIndex = this.currentIndex; this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
} }
save(contentState: ContentState, format: MessageFormat) { save(value: Value, format: MessageFormat) {
const item = new HistoryItem(contentState, format); const item = new HistoryItem(value, format);
this.history.push(item); this.history.push(item);
this.currentIndex = this.lastIndex + 1; this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item)); sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
} }
getItem(offset: number, format: MessageFormat): ?ContentState { getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1); this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
const item = this.history[this.currentIndex]; return this.history[this.currentIndex];
return item ? item.toContentState(format) : null;
} }
} }

View file

@ -14,22 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
class DecryptionFailure { export class DecryptionFailure {
constructor(failedEventId) { constructor(failedEventId, errorCode) {
this.failedEventId = failedEventId; this.failedEventId = failedEventId;
this.errorCode = errorCode;
this.ts = Date.now(); this.ts = Date.now();
} }
} }
export default class DecryptionFailureTracker { export class DecryptionFailureTracker {
// Array of items of type DecryptionFailure. Every `CHECK_INTERVAL_MS`, this list // 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 // is checked for failures that happened > `GRACE_PERIOD_MS` ago. Those that did
// are added to `failuresToTrack`. // are accumulated in `failureCounts`.
failures = []; failures = [];
// Every TRACK_INTERVAL_MS (so as to spread the number of hits done on Analytics), // A histogram of the number of failures that will be tracked at the next tracking
// one DecryptionFailure of this FIFO is removed and tracked. // interval, split by failure error code.
failuresToTrack = []; failureCounts = {
// [errorCode]: 42
};
// Event IDs of failures that were tracked previously // Event IDs of failures that were tracked previously
trackedEventHashMap = { trackedEventHashMap = {
@ -40,23 +43,41 @@ export default class DecryptionFailureTracker {
checkInterval = null; checkInterval = null;
trackInterval = null; trackInterval = null;
// Spread the load on `Analytics` by sending at most 1 event per // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
// `TRACK_INTERVAL_MS`. static TRACK_INTERVAL_MS = 60000;
static TRACK_INTERVAL_MS = 1000;
// Call `checkFailures` every `CHECK_INTERVAL_MS`. // Call `checkFailures` every `CHECK_INTERVAL_MS`.
static CHECK_INTERVAL_MS = 5000; static CHECK_INTERVAL_MS = 5000;
// Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before moving // Give events a chance to be decrypted by waiting `GRACE_PERIOD_MS` before counting
// the failure to `failuresToTrack`. // the failure in `failureCounts`.
static GRACE_PERIOD_MS = 5000; static GRACE_PERIOD_MS = 60000;
constructor(fn) { /**
* 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') { if (!fn || typeof fn !== 'function') {
throw new Error('DecryptionFailureTracker requires tracking function'); throw new Error('DecryptionFailureTracker requires tracking function');
} }
this.trackDecryptionFailure = fn; if (errorCodeMapFn && typeof errorCodeMapFn !== 'function') {
throw new Error('DecryptionFailureTracker second constructor argument should be a function');
}
this._trackDecryptionFailure = fn;
this._mapErrorCode = errorCodeMapFn;
} }
// loadTrackedEventHashMap() { // loadTrackedEventHashMap() {
@ -67,17 +88,17 @@ export default class DecryptionFailureTracker {
// localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap)); // localStorage.setItem('mx-decryption-failure-event-id-hashes', JSON.stringify(this.trackedEventHashMap));
// } // }
eventDecrypted(e) { eventDecrypted(e, err) {
if (e.isDecryptionFailure()) { if (err) {
this.addDecryptionFailureForEvent(e); this.addDecryptionFailure(new DecryptionFailure(e.getId(), err.code));
} else { } else {
// Could be an event in the failures, remove it // Could be an event in the failures, remove it
this.removeDecryptionFailuresForEvent(e); this.removeDecryptionFailuresForEvent(e);
} }
} }
addDecryptionFailureForEvent(e) { addDecryptionFailure(failure) {
this.failures.push(new DecryptionFailure(e.getId())); this.failures.push(failure);
} }
removeDecryptionFailuresForEvent(e) { removeDecryptionFailuresForEvent(e) {
@ -94,7 +115,7 @@ export default class DecryptionFailureTracker {
); );
this.trackInterval = setInterval( this.trackInterval = setInterval(
() => this.trackFailure(), () => this.trackFailures(),
DecryptionFailureTracker.TRACK_INTERVAL_MS, DecryptionFailureTracker.TRACK_INTERVAL_MS,
); );
} }
@ -107,7 +128,7 @@ export default class DecryptionFailureTracker {
clearInterval(this.trackInterval); clearInterval(this.trackInterval);
this.failures = []; this.failures = [];
this.failuresToTrack = []; this.failureCounts = {};
} }
/** /**
@ -154,16 +175,28 @@ export default class DecryptionFailureTracker {
const dedupedFailures = dedupedFailuresMap.values(); const dedupedFailures = dedupedFailuresMap.values();
this.failuresToTrack = [...this.failuresToTrack, ...dedupedFailures]; this._aggregateFailures(dedupedFailures);
}
_aggregateFailures(failures) {
for (const failure of failures) {
const errorCode = failure.errorCode;
this.failureCounts[errorCode] = (this.failureCounts[errorCode] || 0) + 1;
}
} }
/** /**
* If there is a failure that should be tracked, call the given trackDecryptionFailure * If there are failures that should be tracked, call the given trackDecryptionFailure
* function with the first failure in the FIFO of failures that should be tracked. * function with the number of failures that should be tracked.
*/ */
trackFailure() { trackFailures() {
if (this.failuresToTrack.length > 0) { for (const errorCode of Object.keys(this.failureCounts)) {
this.trackDecryptionFailure(this.failuresToTrack.shift()); 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 dis from './dispatcher';
import IntegrationManager from './IntegrationManager'; import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
const WIDGET_API_VERSION = '0.0.1'; // Current API version const WIDGET_API_VERSION = '0.0.1'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [ const SUPPORTED_WIDGET_API_VERSIONS = [
@ -155,6 +156,14 @@ export default class FromWidgetPostMessageApi {
const integType = (data && data.integType) ? data.integType : null; const integType = (data && data.integType) ? data.integType : null;
const integId = (data && data.integId) ? data.integId : null; const integId = (data && data.integId) ? data.integId : null;
IntegrationManager.open(integType, integId); 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 { } else {
console.warn('Widget postMessage event unhandled'); console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was 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. limitations under the License.
*/ */
import React from 'react';
import Modal from './Modal'; import Modal from './Modal';
import sdk from './'; import sdk from './';
import MultiInviter from './utils/MultiInviter'; import MultiInviter from './utils/MultiInviter';

View file

@ -112,7 +112,6 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
/>; />;
} }
export function processHtmlForSending(html: string): string { export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div'); const contentDiv = document.createElement('div');
contentDiv.innerHTML = html; contentDiv.innerHTML = html;
@ -130,13 +129,6 @@ export function processHtmlForSending(html: string): string {
if (i !== contentDiv.children.length - 1) { if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />'; 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 { } else {
const temp = document.createElement('div'); const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true)); 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 = { const sanitizeHtmlParams = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
@ -199,102 +284,14 @@ const sanitizeHtmlParams = {
allowedSchemes: PERMITTED_URL_SCHEMES, allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false, allowProtocolRelative: false,
transformTags,
};
transformTags: { // custom to matrix // this is the same as the above except with less rewriting
// add blank targets to all hyperlinks except vector URLs const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
'a': function(tagName, attribs) { composerSanitizeHtmlParams.transformTags = {
if (attribs.href) { 'code': transformTags['code'],
attribs.target = '_blank'; // by default '*': transformTags['*'],
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: 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 };
},
},
}; };
class BaseHighlighter { class BaseHighlighter {
@ -409,21 +406,30 @@ class TextHighlighter extends BaseHighlighter {
} }
/* turn a matrix event body into html /* turn a matrix event body into html
* *
* content: 'content' of the MatrixEvent * content: 'content' of the MatrixEvent
* *
* highlights: optional list of words to highlight, ordered by longest word first * highlights: optional list of words to highlight, ordered by longest word first
* *
* opts.highlightLink: optional href to add to highlighted words * opts.highlightLink: optional href to add to highlighted words
* opts.disableBigEmoji: optional argument to disable the big emoji class. * 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.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={}) { export function bodyToHtml(content, highlights, opts={}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne;
let bodyHasEmoji = false; let bodyHasEmoji = false;
let sanitizeParams = sanitizeHtmlParams;
if (opts.forComposerQuote) {
sanitizeParams = composerSanitizeHtmlParams;
}
let strippedBody; let strippedBody;
let safeBody; let safeBody;
let isDisplayedWithHtml; let isDisplayedWithHtml;
@ -435,10 +441,10 @@ export function bodyToHtml(content, highlights, opts={}) {
if (highlights && highlights.length > 0) { if (highlights && highlights.length > 0) {
const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
const safeHighlights = highlights.map(function(highlight) { 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. // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure.
sanitizeHtmlParams.textFilter = function(safeText) { sanitizeParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join(''); return highlighter.applyHighlights(safeText, safeHighlights).join('');
}; };
} }
@ -447,19 +453,20 @@ export function bodyToHtml(content, highlights, opts={}) {
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody); if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body; 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 // Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtmlMessage) { if (isHtmlMessage) {
isDisplayedWithHtml = true; isDisplayedWithHtml = true;
safeBody = sanitizeHtml(formattedBody, sanitizeHtmlParams); safeBody = sanitizeHtml(formattedBody, sanitizeParams);
} else { } else {
// ... or if there are emoji, which we insert as HTML alongside the // ... or if there are emoji, which we insert as HTML alongside the
// escaped plaintext body. // escaped plaintext body.
if (bodyHasEmoji) { if (bodyHasEmoji) {
isDisplayedWithHtml = true; isDisplayedWithHtml = true;
safeBody = sanitizeHtml(escape(strippedBody), sanitizeHtmlParams); safeBody = sanitizeHtml(escape(strippedBody), sanitizeParams);
} }
} }
@ -470,7 +477,11 @@ export function bodyToHtml(content, highlights, opts={}) {
safeBody = unicodeToImage(safeBody); safeBody = unicodeToImage(safeBody);
} }
} finally { } finally {
delete sanitizeHtmlParams.textFilter; delete sanitizeParams.textFilter;
}
if (opts.returnString) {
return isDisplayedWithHtml ? safeBody : strippedBody;
} }
let emojiBody = false; let emojiBody = false;

View file

@ -30,6 +30,8 @@ import DMRoomMap from './utils/DMRoomMap';
import RtsClient from './RtsClient'; import RtsClient from './RtsClient';
import Modal from './Modal'; import Modal from './Modal';
import sdk from './index'; import sdk from './index';
import ActiveWidgetStore from './stores/ActiveWidgetStore';
import PlatformPeg from "./PlatformPeg";
/** /**
* Called at startup, to attempt to build a logged-in Matrix session. It tries * Called at startup, to attempt to build a logged-in Matrix session. It tries
@ -236,6 +238,27 @@ async function _restoreFromLocalStorage() {
function _handleLoadSessionFailure(e) { function _handleLoadSessionFailure(e) {
console.log("Unable to load session", e); console.log("Unable to load session", e);
if (e instanceof Matrix.InvalidStoreError) {
if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) {
return Promise.resolve().then(() => {
const lazyLoadEnabled = e.value;
if (lazyLoadEnabled) {
const LazyLoadingResyncDialog =
sdk.getComponent("views.dialogs.LazyLoadingResyncDialog");
return new Promise((resolve) => {
Modal.createDialog(LazyLoadingResyncDialog, {
onFinished: resolve,
});
});
}
}).then(() => {
return MatrixClientPeg.get().store.deleteAllData();
}).then(() => {
PlatformPeg.get().reload();
});
}
}
const def = Promise.defer(); const def = Promise.defer();
const SessionRestoreErrorDialog = const SessionRestoreErrorDialog =
sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); sdk.getComponent('views.dialogs.SessionRestoreErrorDialog');
@ -385,6 +408,8 @@ function _persistCredentialsToLocalStorage(credentials) {
console.log(`Session persisted for ${credentials.userId}`); console.log(`Session persisted for ${credentials.userId}`);
} }
let _isLoggingOut = false;
/** /**
* Logs the current session out and transitions to the logged-out state * Logs the current session out and transitions to the logged-out state
*/ */
@ -404,6 +429,7 @@ export function logout() {
return; return;
} }
_isLoggingOut = true;
MatrixClientPeg.get().logout().then(onLoggedOut, MatrixClientPeg.get().logout().then(onLoggedOut,
(err) => { (err) => {
// Just throwing an error here is going to be very unhelpful // Just throwing an error here is going to be very unhelpful
@ -419,6 +445,10 @@ export function logout() {
).done(); ).done();
} }
export function isLoggingOut() {
return _isLoggingOut;
}
/** /**
* Starts the matrix client and all other react-sdk services that * Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in. * listen for events while a session is logged in.
@ -436,6 +466,7 @@ async function startMatrixClient() {
UserActivity.start(); UserActivity.start();
Presence.start(); Presence.start();
DMRoomMap.makeShared().start(); DMRoomMap.makeShared().start();
ActiveWidgetStore.start();
await MatrixClientPeg.start(); await MatrixClientPeg.start();
@ -449,6 +480,7 @@ async function startMatrixClient() {
* storage. Used after a session has been logged out. * storage. Used after a session has been logged out.
*/ */
export function onLoggedOut() { export function onLoggedOut() {
_isLoggingOut = false;
stopMatrixClient(); stopMatrixClient();
_clearStorage().done(); _clearStorage().done();
dis.dispatch({action: 'on_logged_out'}); dis.dispatch({action: 'on_logged_out'});
@ -488,6 +520,7 @@ export function stopMatrixClient() {
Notifier.stop(); Notifier.stop();
UserActivity.stop(); UserActivity.stop();
Presence.stop(); Presence.stop();
ActiveWidgetStore.stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop();
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {

View file

@ -102,6 +102,16 @@ export default class Markdown {
// (https://github.com/vector-im/riot-web/issues/3154) // (https://github.com/vector-im/riot-web/issues/3154)
softbreak: '<br />', 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; const real_paragraph = renderer.paragraph;
renderer.paragraph = function(node, entering) { renderer.paragraph = function(node, entering) {
@ -115,15 +125,20 @@ export default class Markdown {
} }
}; };
renderer.html_inline = html_if_tag_allowed; renderer.html_inline = html_if_tag_allowed;
renderer.html_block = function(node) { renderer.html_block = function(node) {
/*
// as with `paragraph`, we only insert line breaks // as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown. // if there are multiple lines in the markdown.
const isMultiLine = is_multi_line(node); const isMultiLine = is_multi_line(node);
if (isMultiLine) this.cr(); if (isMultiLine) this.cr();
*/
html_if_tag_allowed.call(this, node); html_if_tag_allowed.call(this, node);
/*
if (isMultiLine) this.cr(); if (isMultiLine) this.cr();
*/
}; };
return renderer.render(this.parsed); return renderer.render(this.parsed);
@ -133,7 +148,10 @@ export default class Markdown {
* Render the markdown message to plain text. That is, essentially * Render the markdown message to plain text. That is, essentially
* just remove any backslashes escaping what would otherwise be * just remove any backslashes escaping what would otherwise be
* markdown syntax * 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() { toPlaintext() {
const renderer = new commonmark.HtmlRenderer({safe: false}); const renderer = new commonmark.HtmlRenderer({safe: false});
@ -156,6 +174,7 @@ export default class Markdown {
} }
} }
}; };
renderer.html_block = function(node) { renderer.html_block = function(node) {
this.lit(node.literal); this.lit(node.literal);
if (is_multi_line(node) && node.next) this.lit('\n\n'); if (is_multi_line(node) && node.next) this.lit('\n\n');

View file

@ -99,13 +99,17 @@ class MatrixClientPeg {
// the react sdk doesn't work without this, so don't allow // the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached"; opts.pendingEventOrdering = "detached";
if (SettingsStore.isFeatureEnabled('feature_lazyloading')) {
opts.lazyLoadMembers = true;
}
try { try {
const promise = this.matrixClient.store.startup(); const promise = this.matrixClient.store.startup();
console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`); console.log(`MatrixClientPeg: waiting for MatrixClient store to initialise`);
await promise; await promise;
} catch (err) { } catch (err) {
// log any errors when starting up the database (if one exists) // log any errors when starting up the database (if one exists)
console.error(`Error starting matrixclient store: ${err}`); console.error('Error starting matrixclient store', err);
} }
// regardless of errors, start the client. If we did error out, we'll // regardless of errors, start the client. If we did error out, we'll
@ -115,7 +119,7 @@ class MatrixClientPeg {
MatrixActionCreators.start(this.matrixClient); MatrixActionCreators.start(this.matrixClient);
console.log(`MatrixClientPeg: really starting MatrixClient`); console.log(`MatrixClientPeg: really starting MatrixClient`);
this.get().startClient(opts); await this.get().startClient(opts);
console.log(`MatrixClientPeg: MatrixClient started`); console.log(`MatrixClientPeg: MatrixClient started`);
} }

View file

@ -170,15 +170,15 @@ const Notifier = {
value: true, 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 { } else {
dis.dispatch({ dis.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
value: false, 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() { isEnabled: function() {

92
src/Registration.js Normal file
View file

@ -0,0 +1,92 @@
/*
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.
*/
/**
* Utility code for registering with a homeserver
* Note that this is currently *not* used by the actual
* registration code.
*/
import dis from './dispatcher';
import sdk from './index';
import MatrixClientPeg from './MatrixClientPeg';
import Modal from './Modal';
import { _t } from './languageHandler';
/**
* Starts either the ILAG or full registration flow, depending
* on what the HS supports
*
* @param {object} options
* @param {bool} options.go_home_on_cancel If true, goes to
* the hame page if the user cancels the action
*/
export async function startAnyRegistrationFlow(options) {
if (options === undefined) options = {};
const flows = await _getRegistrationFlows();
// look for an ILAG compatible flow. We define this as one
// which has only dummy or recaptcha flows. In practice it
// would support any stage InteractiveAuth supports, just not
// ones like email & msisdn which require the user to supply
// the relevant details in advance. We err on the side of
// caution though.
const hasIlagFlow = flows.some((flow) => {
return flow.stages.every((stage) => {
return ['m.login.dummy', 'm.login.recaptcha'].includes(stage);
});
});
if (hasIlagFlow) {
dis.dispatch({
action: 'view_set_mxid',
go_home_on_cancel: options.go_home_on_cancel,
});
} else {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Registration required', '', QuestionDialog, {
title: _t("Registration Required"),
description: _t("You need to register to do this. Would you like to register now?"),
button: _t("Register"),
onFinished: (proceed) => {
if (proceed) {
dis.dispatch({action: 'start_registration'});
} else if (options.go_home_on_cancel) {
dis.dispatch({action: 'view_home_page'});
}
},
});
}
}
async function _getRegistrationFlows() {
try {
await MatrixClientPeg.get().register(
null,
null,
undefined,
{},
{},
);
console.log("Register request succeeded when it should have returned 401!");
} catch (e) {
if (e.httpStatus === 401) {
return e.data.flows;
}
throw e;
}
throw new Error("Register request succeeded when it should have returned 401!");
}

View file

@ -1,307 +1,40 @@
import React from 'react'; /*
import { Copyright 2015 - 2017 OpenMarket Ltd
Editor, Copyright 2017 Vector Creations Ltd
EditorState, Copyright 2018 New Vector Ltd
Modifier,
ContentState, Licensed under the Apache License, Version 2.0 (the "License");
ContentBlock, you may not use this file except in compliance with the License.
convertFromHTML, You may obtain a copy of the License at
DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle, http://www.apache.org/licenses/LICENSE-2.0
CompositeDecorator,
SelectionState, Unless required by applicable law or agreed to in writing, software
Entity, distributed under the License is distributed on an "AS IS" BASIS,
} from 'draft-js'; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
import * as sdk from './index'; See the License for the specific language governing permissions and
limitations under the License.
*/
import * as emojione from 'emojione'; 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; export function unicodeToEmojiUri(str) {
const ROOM_REGEX = /#\S+:\S+/g; const mappedUnicode = emojione.mapUnicodeToShort();
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
const ZWS_CODE = 8203; // remove any zero width joiners/spaces used in conjugate emojis as the emojione URIs don't contain them
const ZWS = String.fromCharCode(ZWS_CODE); // zero width space return str.replace(emojione.regUnicode, function(unicodeChar) {
export function stateToMarkdown(state) { if ((typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap))) {
return __stateToMarkdown(state) // if the unicodeChar doesn't exist just return the entire match
.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
return unicodeChar; return unicodeChar;
} else { } 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 // 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) { function _getDirectMessageRooms(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
const dmRooms = dmRoomMap.getDMRoomsForUserId(addr); const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
const rooms = []; const rooms = dmRooms.filter((dmRoom) => {
dmRooms.forEach((dmRoom) => {
const room = MatrixClientPeg.get().getRoom(dmRoom); const room = MatrixClientPeg.get().getRoom(dmRoom);
if (room) { if (room) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId); return room.getMyMembership() === 'join';
if (me.membership == 'join') {
rooms.push(room);
}
} }
}); });
return rooms; return rooms;

View file

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

View file

@ -63,25 +63,24 @@ class ScalarAuthClient {
validateToken(token) { validateToken(token) {
let url = SdkConfig.get().integrations_rest_url + "/account"; let url = SdkConfig.get().integrations_rest_url + "/account";
const defer = Promise.defer(); return new Promise(function(resolve, reject) {
request({ request({
method: "GET", method: "GET",
uri: url, uri: url,
qs: {scalar_token: token}, qs: {scalar_token: token},
json: true, json: true,
}, (err, response, body) => { }, (err, response, body) => {
if (err) { if (err) {
defer.reject(err); reject(err);
} else if (response.statusCode / 100 !== 2) { } else if (response.statusCode / 100 !== 2) {
defer.reject({statusCode: response.statusCode}); reject({statusCode: response.statusCode});
} else if (!body || !body.user_id) { } else if (!body || !body.user_id) {
defer.reject(new Error("Missing user_id in response")); reject(new Error("Missing user_id in response"));
} else { } else {
defer.resolve(body.user_id); resolve(body.user_id);
} }
}); });
})
return defer.promise;
} }
registerForToken() { registerForToken() {
@ -96,56 +95,54 @@ class ScalarAuthClient {
} }
exchangeForScalarToken(openid_token_object) { exchangeForScalarToken(openid_token_object) {
const defer = Promise.defer();
const scalar_rest_url = SdkConfig.get().integrations_rest_url; const scalar_rest_url = SdkConfig.get().integrations_rest_url;
request({
method: 'POST',
uri: scalar_rest_url+'/register',
body: openid_token_object,
json: true,
}, (err, response, body) => {
if (err) {
defer.reject(err);
} else if (response.statusCode / 100 !== 2) {
defer.reject({statusCode: response.statusCode});
} else if (!body || !body.scalar_token) {
defer.reject(new Error("Missing scalar_token in response"));
} else {
defer.resolve(body.scalar_token);
}
});
return defer.promise; return new Promise(function(resolve, reject) {
request({
method: 'POST',
uri: scalar_rest_url+'/register',
body: openid_token_object,
json: true,
}, (err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
} else if (!body || !body.scalar_token) {
reject(new Error("Missing scalar_token in response"));
} else {
resolve(body.scalar_token);
}
});
})
} }
getScalarPageTitle(url) { getScalarPageTitle(url) {
const defer = Promise.defer();
let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup'; let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup';
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
request({
method: 'GET',
uri: scalarPageLookupUrl,
json: true,
}, (err, response, body) => {
if (err) {
defer.reject(err);
} else if (response.statusCode / 100 !== 2) {
defer.reject({statusCode: response.statusCode});
} else if (!body) {
defer.reject(new Error("Missing page title in response"));
} else {
let title = "";
if (body.page_title_cache_item && body.page_title_cache_item.cached_title) {
title = body.page_title_cache_item.cached_title;
}
defer.resolve(title);
}
});
return defer.promise; return new Promise(function(resolve, reject) {
request({
method: 'GET',
uri: scalarPageLookupUrl,
json: true,
}, (err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
} else if (!body) {
reject(new Error("Missing page title in response"));
} else {
let title = "";
if (body.page_title_cache_item && body.page_title_cache_item.cached_title) {
title = body.page_title_cache_item.cached_title;
}
resolve(title);
}
});
})
} }
/** /**

View file

@ -236,8 +236,7 @@ import SdkConfig from './SdkConfig';
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import { MatrixEvent } from 'matrix-js-sdk'; import { MatrixEvent } from 'matrix-js-sdk';
import dis from './dispatcher'; import dis from './dispatcher';
import Widgets from './utils/widgets'; import WidgetUtils from './utils/WidgetUtils';
import WidgetUtils from './WidgetUtils';
import RoomViewStore from './stores/RoomViewStore'; import RoomViewStore from './stores/RoomViewStore';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
@ -297,12 +296,6 @@ function setWidget(event, roomId) {
const widgetData = event.data.data; // optional const widgetData = event.data.data; // optional
const userWidget = event.data.userWidget; const userWidget = event.data.userWidget;
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
// both adding/removing widgets need these checks // both adding/removing widgets need these checks
if (!widgetId || widgetUrl === undefined) { if (!widgetId || widgetUrl === undefined) {
sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields.")); sendError(event, _t("Unable to create widget."), new Error("Missing required widget fields."));
@ -329,42 +322,8 @@ function setWidget(event, roomId) {
} }
} }
let content = {
type: widgetType,
url: widgetUrl,
name: widgetName,
data: widgetData,
};
if (userWidget) { if (userWidget) {
const client = MatrixClientPeg.get(); WidgetUtils.setUserWidget(widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
const userWidgets = Widgets.getUserWidgets();
// Delete existing widget with ID
try {
delete userWidgets[widgetId];
} catch (e) {
console.error(`$widgetId is non-configurable`);
}
// Add new widget / update
if (widgetUrl !== null) {
userWidgets[widgetId] = {
content: content,
sender: client.getUserId(),
state_key: widgetId,
type: 'm.widget',
id: widgetId,
};
}
// This starts listening for when the echo comes back from the server
// since the widget won't appear added until this happens. If we don't
// wait for this, the action will complete but if the user is fast enough,
// the widget still won't actually be there.
client.setAccountData('m.widgets', userWidgets).then(() => {
return WidgetUtils.waitForUserWidget(widgetId, widgetUrl !== null);
}).then(() => {
sendResponse(event, { sendResponse(event, {
success: true, success: true,
}); });
@ -377,15 +336,7 @@ function setWidget(event, roomId) {
if (!roomId) { if (!roomId) {
sendError(event, _t('Missing roomId.'), null); sendError(event, _t('Missing roomId.'), null);
} }
WidgetUtils.setRoomWidget(roomId, widgetId, widgetType, widgetUrl, widgetName, widgetData).then(() => {
if (widgetUrl === null) { // widget is being deleted
content = {};
}
// TODO - Room widgets need to be moved to 'm.widget' state events
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).then(() => {
return WidgetUtils.waitForRoomWidget(widgetId, roomId, widgetUrl !== null);
}).then(() => {
sendResponse(event, { sendResponse(event, {
success: true, success: true,
}); });
@ -409,21 +360,13 @@ function getWidgets(event, roomId) {
sendError(event, _t('This room is not recognised.')); sendError(event, _t('This room is not recognised.'));
return; return;
} }
// TODO - Room widgets need to be moved to 'm.widget' state events // XXX: This gets the raw event object (I think because we can't
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing // send the MatrixEvent over postMessage?)
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); widgetStateEvents = WidgetUtils.getRoomWidgets(room).map((ev) => ev.event);
// Only return widgets which have required fields
if (room) {
stateEvents.forEach((ev) => {
if (ev.getContent().type && ev.getContent().url) {
widgetStateEvents.push(ev.event); // return the raw event
}
});
}
} }
// Add user widgets (not linked to a specific room) // Add user widgets (not linked to a specific room)
const userWidgets = Widgets.getUserWidgetsArray(); const userWidgets = WidgetUtils.getUserWidgetsArray();
widgetStateEvents = widgetStateEvents.concat(userWidgets); widgetStateEvents = widgetStateEvents.concat(userWidgets);
sendResponse(event, widgetStateEvents); sendResponse(event, widgetStateEvents);
@ -537,7 +480,7 @@ function getMembershipCount(event, roomId) {
sendError(event, _t('This room is not recognised.')); sendError(event, _t('This room is not recognised.'));
return; return;
} }
const count = room.getJoinedMembers().length; const count = room.getJoinedMemberCount();
sendResponse(event, count); sendResponse(event, count);
} }
@ -554,12 +497,11 @@ function canSendEvent(event, roomId) {
sendError(event, _t('This room is not recognised.')); sendError(event, _t('This room is not recognised.'));
return; return;
} }
const me = client.credentials.userId; if (room.getMyMembership() !== "join") {
const member = room.getMember(me);
if (!member || member.membership !== "join") {
sendError(event, _t('You are not in this room.')); sendError(event, _t('You are not in this room.'));
return; return;
} }
const me = client.credentials.userId;
let canSend = false; let canSend = false;
if (isState) { if (isState) {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -26,11 +27,12 @@ import SettingsStore, {SettingLevel} from './settings/SettingsStore';
class Command { class Command {
constructor({name, args='', description, runFn}) { constructor({name, args='', description, runFn, hideCompletionAfterSpace=false}) {
this.command = '/' + name; this.command = '/' + name;
this.args = args; this.args = args;
this.description = description; this.description = description;
this.runFn = runFn; this.runFn = runFn;
this.hideCompletionAfterSpace = hideCompletionAfterSpace;
} }
getCommand() { getCommand() {
@ -78,6 +80,7 @@ export const CommandMap = {
}); });
return success(); return success();
}, },
hideCompletionAfterSpace: true,
}), }),
nick: new Command({ nick: new Command({
@ -466,6 +469,20 @@ export const CommandMap = {
name: 'me', name: 'me',
args: '<message>', args: '<message>',
description: _td('Displays action'), description: _td('Displays action'),
hideCompletionAfterSpace: true,
}),
discardsession: new Command({
name: 'discardsession',
description: _td('Forces the current outbound group session in an encrypted room to be discarded'),
runFn: function(roomId) {
try {
MatrixClientPeg.get().forceDiscardSession(roomId);
} catch (e) {
return reject(e.message);
}
return success();
},
}), }),
}; };
/* eslint-enable babel/no-invalid-this */ /* eslint-enable babel/no-invalid-this */
@ -474,8 +491,10 @@ export const CommandMap = {
// helpful aliases // helpful aliases
const aliases = { const aliases = {
j: "join", j: "join",
newballsplease: "discardsession",
}; };
/** /**
* Process the given text for /commands and perform them. * Process the given text for /commands and perform them.
* @param {string} roomId The room in which the command was performed. * @param {string} roomId The room in which the command was performed.
@ -488,7 +507,7 @@ export function processCommandInput(roomId, input) {
// trim any trailing whitespace, as it can confuse the parser for // trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands // IRC-style commands
input = input.replace(/\s+$/, ''); input = input.replace(/\s+$/, '');
if (input[0] !== '/' || input[1] === '/') return null; // not a command if (input[0] !== '/') return null; // not a command
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
let cmd; let cmd;

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) { function textForMessageEvent(ev) {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
let message = senderDisplayName + ': ' + ev.getContent().body; let message = senderDisplayName + ': ' + ev.getContent().body;
@ -140,6 +198,63 @@ function textForMessageEvent(ev) {
return message; return message;
} }
function textForRoomAliasesEvent(ev) {
// An alternative implementation of this as a first-class event can be found at
// https://github.com/matrix-org/matrix-react-sdk/blob/dc7212ec2bd12e1917233ed7153b3e0ef529a135/src/components/views/messages/RoomAliasesEvent.js
// This feels a bit overkill though, and it's not clear the i18n really needs it
// so instead it's landing as a simple textual event.
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAliases = ev.getPrevContent().aliases || [];
const newAliases = ev.getContent().aliases || [];
const addedAliases = newAliases.filter((x) => !oldAliases.includes(x));
const removedAliases = oldAliases.filter((x) => !newAliases.includes(x));
if (!addedAliases.length && !removedAliases.length) {
return '';
}
if (addedAliases.length && !removedAliases.length) {
return _t('%(senderName)s added %(count)s %(addedAddresses)s as addresses for this room.', {
senderName: senderName,
count: addedAliases.length,
addedAddresses: addedAliases.join(', '),
});
} else if (!addedAliases.length && removedAliases.length) {
return _t('%(senderName)s removed %(count)s %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
count: removedAliases.length,
removedAddresses: removedAliases.join(', '),
});
} else {
return _t(
'%(senderName)s added %(addedAddresses)s and removed %(removedAddresses)s as addresses for this room.', {
senderName: senderName,
addedAddresses: addedAliases.join(', '),
removedAddresses: removedAliases.join(', '),
},
);
}
}
function textForCanonicalAliasEvent(ev) {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias;
const newAlias = ev.getContent().alias;
if (newAlias) {
return _t('%(senderName)s set the main address for this room to %(address)s.', {
senderName: senderName,
address: ev.getContent().alias,
});
} else if (oldAlias) {
return _t('%(senderName)s removed the main address for this room.', {
senderName: senderName,
});
}
}
function textForCallAnswerEvent(event) { function textForCallAnswerEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone'); const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
@ -157,6 +272,12 @@ function textForCallHangupEvent(event) {
reason = _t('(could not connect media)'); reason = _t('(could not connect media)');
} else if (eventContent.reason === "invite_timeout") { } else if (eventContent.reason === "invite_timeout") {
reason = _t('(no answer)'); reason = _t('(no answer)');
} else if (eventContent.reason === "user hangup") {
// workaround for https://github.com/vector-im/riot-web/issues/5178
// it seems Android randomly sets a reason of "user hangup" which is
// interpreted as an error code :(
// https://github.com/vector-im/riot-android/issues/2623
reason = '';
} else { } else {
reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason});
} }
@ -301,6 +422,8 @@ const handlers = {
}; };
const stateHandlers = { const stateHandlers = {
'm.room.aliases': textForRoomAliasesEvent,
'm.room.canonical_alias': textForCanonicalAliasEvent,
'm.room.name': textForRoomNameEvent, 'm.room.name': textForRoomNameEvent,
'm.room.topic': textForTopicEvent, 'm.room.topic': textForTopicEvent,
'm.room.member': textForMemberEvent, 'm.room.member': textForMemberEvent,
@ -309,6 +432,7 @@ const stateHandlers = {
'm.room.encryption': textForEncryptionEvent, 'm.room.encryption': textForEncryptionEvent,
'm.room.power_levels': textForPowerEvent, 'm.room.power_levels': textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent, 'm.room.pinned_events': textForPinnedEvent,
'm.room.server_acl': textForServerACLEvent,
'im.vector.modular.widgets': textForWidgetEvent, 'im.vector.modular.widgets': textForWidgetEvent,
}; };

View file

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

View file

@ -1,193 +0,0 @@
/*
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 MatrixClientPeg from './MatrixClientPeg';
import SdkConfig from "./SdkConfig";
import * as url from "url";
export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room
* (Does not apply to non-room-based / user widgets)
* @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason
*/
static canUserModifyWidgets(roomId) {
if (!roomId) {
console.warn('No room ID specified');
return false;
}
const client = MatrixClientPeg.get();
if (!client) {
console.warn('User must be be logged in');
return false;
}
const room = client.getRoom(roomId);
if (!room) {
console.warn(`Room ID ${roomId} is not recognised`);
return false;
}
const me = client.credentials.userId;
if (!me) {
console.warn('Failed to get user ID');
return false;
}
const member = room.getMember(me);
if (!member || member.membership !== "join") {
console.warn(`User ${me} is not in room ${roomId}`);
return false;
}
return room.currentState.maySendStateEvent('im.vector.modular.widgets', me);
}
/**
* Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api
* @param {[type]} testUrlString URL to check
* @return {Boolean} True if specified URL is a scalar URL
*/
static isScalarUrl(testUrlString) {
if (!testUrlString) {
console.error('Scalar URL check failed. No URL specified');
return false;
}
const testUrl = url.parse(testUrlString);
let scalarUrls = SdkConfig.get().integrations_widgets_urls;
if (!scalarUrls || scalarUrls.length === 0) {
scalarUrls = [SdkConfig.get().integrations_rest_url];
}
for (let i = 0; i < scalarUrls.length; i++) {
const scalarUrl = url.parse(scalarUrls[i]);
if (testUrl && scalarUrl) {
if (
testUrl.protocol === scalarUrl.protocol &&
testUrl.host === scalarUrl.host &&
testUrl.pathname.startsWith(scalarUrl.pathname)
) {
return true;
}
}
}
return false;
}
/**
* 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 in the
* requested state according to the `add` param
*/
static waitForUserWidget(widgetId, add) {
return new Promise((resolve, reject) => {
// 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 || !ev.getContent()) return false;
if (add) {
return ev.getContent()[widgetId] !== undefined;
} else {
return ev.getContent()[widgetId] === undefined;
}
}
const startingAccountDataEvent = MatrixClientPeg.get().getAccountData('m.widgets');
if (eventInIntendedState(startingAccountDataEvent)) {
resolve();
return;
}
function onAccountData(ev) {
const currentAccountDataEvent = MatrixClientPeg.get().getAccountData('m.widgets');
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);
});
}
/**
* Returns a promise that resolves when a widget with the given
* ID has been added as a room widget in the given room (ie. the
* room state event arrives) or rejects after a timeout
*
* @param {string} widgetId The ID of the widget to wait for
* @param {string} roomId The ID of the room to wait for the widget in
* @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 in the
* requested state according to the `add` param
*/
static waitForRoomWidget(widgetId, roomId, add) {
return new Promise((resolve, reject) => {
// Tests a list of state events, returning true if it's in the state
// we're waiting for it to be in
function eventsInIntendedState(evList) {
const widgetPresent = evList.some((ev) => {
return ev.getContent() && ev.getContent()['id'] === widgetId;
});
if (add) {
return widgetPresent;
} else {
return !widgetPresent;
}
}
const room = MatrixClientPeg.get().getRoom(roomId);
const startingWidgetEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
if (eventsInIntendedState(startingWidgetEvents)) {
resolve();
return;
}
function onRoomStateEvents(ev) {
if (ev.getRoomId() !== roomId) return;
const currentWidgetEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
if (eventsInIntendedState(currentWidgetEvents)) {
MatrixClientPeg.get().removeListener('RoomState.events', onRoomStateEvents);
clearTimeout(timerId);
resolve();
}
}
const timerId = setTimeout(() => {
MatrixClientPeg.get().removeListener('RoomState.events', onRoomStateEvents);
reject(new Error("Timed out waiting for widget ID " + widgetId + " to appear"));
}, 10000);
MatrixClientPeg.get().on('RoomState.events', onRoomStateEvents);
});
}
}

View file

@ -144,23 +144,25 @@ function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTi
/** /**
* @typedef RoomMembershipAction * @typedef RoomMembershipAction
* @type {Object} * @type {Object}
* @property {string} action 'MatrixActions.RoomMember.membership'. * @property {string} action 'MatrixActions.Room.myMembership'.
* @property {RoomMember} member the member whose membership was updated. * @property {Room} room to room for which the self-membership changed.
* @property {string} membership the new membership
* @property {string} oldMembership the previous membership, can be null.
*/ */
/** /**
* Create a MatrixActions.RoomMember.membership action that represents * Create a MatrixActions.Room.myMembership action that represents
* a MatrixClient `RoomMember.membership` matrix event, emitted when a * a MatrixClient `Room.myMembership` event for the syncing user,
* member's membership is updated. * emitted when the syncing user's membership is updated for a room.
* *
* @param {MatrixClient} matrixClient the matrix client. * @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} membershipEvent the m.room.member event. * @param {Room} room to room for which the self-membership changed.
* @param {RoomMember} member the member whose membership was updated. * @param {string} membership the new membership
* @param {string} oldMembership the member's previous membership. * @param {string} oldMembership the previous membership, can be null.
* @returns {RoomMembershipAction} an action of type `MatrixActions.RoomMember.membership`. * @returns {RoomMembershipAction} an action of type `MatrixActions.Room.myMembership`.
*/ */
function createRoomMembershipAction(matrixClient, membershipEvent, member, oldMembership) { function createSelfMembershipAction(matrixClient, room, membership, oldMembership) {
return { action: 'MatrixActions.RoomMember.membership', member }; return { action: 'MatrixActions.Room.myMembership', room, membership, oldMembership};
} }
/** /**
@ -202,7 +204,7 @@ export default {
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction); this._addMatrixClientListener(matrixClient, 'Room.myMembership', createSelfMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
}, },
@ -217,7 +219,10 @@ export default {
*/ */
_addMatrixClientListener(matrixClient, eventName, actionCreator) { _addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => { const listener = (...args) => {
dis.dispatch(actionCreator(matrixClient, ...args), true); const payload = actionCreator(matrixClient, ...args);
if (payload) {
dis.dispatch(payload, true);
}
}; };
matrixClient.on(eventName, listener); matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => { this._matrixClientListenersStop.push(() => {

View file

@ -20,13 +20,19 @@ import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter'; import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp) { constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) { if (commandRegex) {
if (!commandRegex.global) { if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set'); throw new Error('commandRegex must have global flag set');
} }
this.commandRegex = commandRegex; this.commandRegex = commandRegex;
} }
if (forcedCommandRegex) {
if (!forcedCommandRegex.global) {
throw new Error('forcedCommandRegex must have global flag set');
}
this.forcedCommandRegex = forcedCommandRegex;
}
} }
destroy() { destroy() {
@ -40,7 +46,7 @@ export default class AutocompleteProvider {
let commandRegex = this.commandRegex; let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) { if (force && this.shouldForceComplete()) {
commandRegex = /\S+/g; commandRegex = this.forcedCommandRegex || /\S+/g;
} }
if (commandRegex == null) { if (commandRegex == null) {

View file

@ -29,8 +29,9 @@ import NotifProvider from './NotifProvider';
import Promise from 'bluebird'; import Promise from 'bluebird';
export type SelectionRange = { export type SelectionRange = {
start: number, beginning: boolean, // whether the selection is in the first block of the editor or not
end: number 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 = { export type Completion = {
@ -80,12 +81,12 @@ export default class Autocompleter {
// Array of inspections of promises that might timeout. Instead of allowing a // 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 // single timeout to reject the Promise.all, reflect each one and once they've all
// settled, filter for the fulfilled ones // settled, filter for the fulfilled ones
this.providers.map((provider) => { this.providers.map(provider =>
return provider provider
.getCompletions(query, selection, force) .getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT) .timeout(PROVIDER_COMPLETION_TIMEOUT)
.reflect(); .reflect()
}), ),
); );
return completionsList.filter( return completionsList.filter(

View file

@ -42,10 +42,13 @@ export default class CommandProvider extends AutocompleteProvider {
if (!command) return []; if (!command) return [];
let matches = []; 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]) { if (command[0] !== command[1]) {
// The input looks like a command with arguments, perform exact match // The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/` const name = command[1].substr(1); // strip leading `/`
if (CommandMap[name]) { 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]]; matches = [CommandMap[name]];
} }
} else { } else {

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2018 New Vector Ltd Copyright 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -26,7 +27,7 @@ import {makeGroupPermalink} from "../matrix-to";
import type {Completion, SelectionRange} from "./Autocompleter"; import type {Completion, SelectionRange} from "./Autocompleter";
import FlairStore from "../stores/FlairStore"; import FlairStore from "../stores/FlairStore";
const COMMUNITY_REGEX = /(?=\+)(\S*)/g; const COMMUNITY_REGEX = /\B\+\S*/g;
function score(query, space) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);

View file

@ -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 // (^|\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 // 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. // 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, // 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. // 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 { return {
name: a.name, name: a.name,
shortname: a.shortname, shortname: a.shortname,
aliases: a.aliases ? a.aliases.join(' ') : '',
aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '', aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '',
// Include the index so that we can preserve the original order // Include the index so that we can preserve the original order
_orderBy: index, _orderBy: index,
@ -84,7 +85,7 @@ export default class EmojiProvider extends AutocompleteProvider {
constructor() { constructor() {
super(EMOJI_REGEX); super(EMOJI_REGEX);
this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, { this.matcher = new FuzzyMatcher(EMOJI_SHORTNAMES, {
keys: ['aliases_ascii', 'shortname'], keys: ['aliases_ascii', 'shortname', 'aliases'],
// For matching against ascii equivalents // For matching against ascii equivalents
shouldMatchWordsOnly: false, shouldMatchWordsOnly: false,
}); });

View file

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

View file

@ -2,6 +2,7 @@
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -28,7 +29,7 @@ import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../matrix-to"; import {makeRoomPermalink} from "../matrix-to";
import type {Completion, SelectionRange} from "./Autocompleter"; import type {Completion, SelectionRange} from "./Autocompleter";
const ROOM_REGEX = /(?=#)(\S*)/g; const ROOM_REGEX = /\B#\S*/g;
function score(query, space) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
@ -50,12 +51,6 @@ export default class RoomProvider extends AutocompleteProvider {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> { async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force); const {command, range} = this.getCurrentCommand(query, selection, force);
@ -79,6 +74,7 @@ export default class RoomProvider extends AutocompleteProvider {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return { return {
completion: displayAlias, completion: displayAlias,
completionId: displayAlias,
suffix: ' ', suffix: ' ',
href: makeRoomPermalink(displayAlias), href: makeRoomPermalink(displayAlias),
component: ( component: (

View file

@ -3,6 +3,7 @@
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -26,25 +27,27 @@ import FuzzyMatcher from './FuzzyMatcher';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk'; import type {MatrixEvent, Room, RoomMember, RoomState} from 'matrix-js-sdk';
import {makeUserPermalink} from "../matrix-to"; import {makeUserPermalink} from "../matrix-to";
import type {SelectionRange} from "./Autocompleter"; 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 { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null; users: Array<RoomMember> = null;
room: Room = null; room: Room = null;
constructor(room: Room) { constructor(room) {
super(USER_REGEX, { super(USER_REGEX, FORCED_USER_REGEX);
keys: ['name'],
});
this.room = room; this.room = room;
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
shouldMatchPrefix: true, shouldMatchPrefix: true,
shouldMatchWordsOnly: false shouldMatchWordsOnly: false,
}); });
this._onRoomTimelineBound = this._onRoomTimeline.bind(this); this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
@ -61,7 +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 (!room) return;
if (removed) return; if (removed) return;
if (room.roomId !== this.room.roomId) return; if (room.roomId !== this.room.roomId) return;
@ -77,7 +80,7 @@ export default class UserProvider extends AutocompleteProvider {
this.onUserSpoke(ev.sender); this.onUserSpoke(ev.sender);
} }
_onRoomStateMember(ev, state, member) { _onRoomStateMember(ev: MatrixEvent, state: RoomState, member: RoomMember) {
// ignore members in other rooms // ignore members in other rooms
if (member.roomId !== this.room.roomId) { if (member.roomId !== this.room.roomId) {
return; return;
@ -87,15 +90,9 @@ export default class UserProvider extends AutocompleteProvider {
this.users = null; this.users = null;
} }
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false) { async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
// (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 // lazy-load user list into matcher
if (this.users === null) this._makeUsers(); 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 // 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. // relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName, completion: user.rawDisplayName,
suffix: range.start === 0 ? ': ' : ' ', completionId: user.userId,
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId), href: makeUserPermalink(user.userId),
component: ( component: (
<PillCompletion <PillCompletion
@ -128,7 +126,7 @@ export default class UserProvider extends AutocompleteProvider {
return completions; return completions;
} }
getName() { getName(): string {
return '👥 ' + _t('Users'); return '👥 ' + _t('Users');
} }
@ -141,13 +139,9 @@ export default class UserProvider extends AutocompleteProvider {
} }
const currentUserId = MatrixClientPeg.get().credentials.userId; const currentUserId = MatrixClientPeg.get().credentials.userId;
this.users = this.room.getJoinedMembers().filter((member) => { this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
if (member.userId !== currentUserId) return true;
});
this.users = _sortBy(this.users, (member) => this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
1E20 - lastSpoken[member.userId] || 1E20,
);
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }

View file

@ -64,7 +64,9 @@ export default class ContextualMenu extends React.Component {
// The component to render as the context menu // The component to render as the context menu
elementClass: PropTypes.element.isRequired, elementClass: PropTypes.element.isRequired,
// on resize callback // on resize callback
windowResize: PropTypes.func windowResize: PropTypes.func,
// method to close menu
closeMenu: PropTypes.func,
}; };
constructor() { constructor() {
@ -73,6 +75,7 @@ export default class ContextualMenu extends React.Component {
contextMenuRect: null, contextMenuRect: null,
}; };
this.onContextMenu = this.onContextMenu.bind(this);
this.collectContextMenuRect = this.collectContextMenuRect.bind(this); this.collectContextMenuRect = this.collectContextMenuRect.bind(this);
} }
@ -85,6 +88,28 @@ export default class ContextualMenu extends React.Component {
}); });
} }
onContextMenu(e) {
if (this.props.closeMenu) {
this.props.closeMenu();
e.preventDefault();
const x = e.clientX;
const y = e.clientY;
// XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst
// a context menu and its click-guard are up without completely rewriting how the context menus work.
setImmediate(() => {
const clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent(
'contextmenu', true, true, window, 0,
0, 0, x, y, false, false,
false, false, 0, null,
);
document.elementFromPoint(x, y).dispatchEvent(clickEvent);
});
}
}
render() { render() {
const position = {}; const position = {};
let chevronFace = null; let chevronFace = null;
@ -195,7 +220,8 @@ export default class ContextualMenu extends React.Component {
{ chevron } { chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} /> <ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div> </div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu} /> } { props.hasBackground && <div className="mx_ContextualMenu_background"
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style> <style>{ chevronCSS }</style>
</div>; </div>;
} }

View file

@ -480,7 +480,7 @@ export default React.createClass({
group_id: groupId, group_id: groupId,
}, },
}); });
dis.dispatch({action: 'view_set_mxid'}); dis.dispatch({action: 'require_registration'});
willDoOnboarding = true; willDoOnboarding = true;
} }
this.setState({ this.setState({
@ -723,6 +723,11 @@ export default React.createClass({
}, },
_onJoinClick: async function() { _onJoinClick: async function() {
if (this._matrixClient.isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the

View file

@ -1,7 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -30,10 +30,16 @@ import dis from '../../dispatcher';
import sessionStore from '../../stores/SessionStore'; import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import RoomListStore from "../../stores/RoomListStore";
import TagOrderActions from '../../actions/TagOrderActions'; import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions'; 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 * This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property. * determined by the page_type property.
@ -80,6 +86,8 @@ const LoggedInView = React.createClass({
return { return {
// use compact timeline view // use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'), useCompactLayout: SettingsStore.getValue('useCompactLayout'),
// any currently active server notice events
serverNoticeEvents: [],
}; };
}, },
@ -97,12 +105,18 @@ const LoggedInView = React.createClass({
); );
this._setStateFromSessionStore(); this._setStateFromSessionStore();
this._updateServerNoticeEvents();
this._matrixClient.on("accountData", this.onAccountData); this._matrixClient.on("accountData", this.onAccountData);
this._matrixClient.on("sync", this.onSync);
this._matrixClient.on("RoomState.events", this.onRoomStateEvents);
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
document.removeEventListener('keydown', this._onKeyDown); document.removeEventListener('keydown', this._onKeyDown);
this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
if (this._sessionStoreToken) { if (this._sessionStoreToken) {
this._sessionStoreToken.remove(); 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) { _onKeyDown: function(ev) {
/* /*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers // Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
@ -259,15 +323,15 @@ const LoggedInView = React.createClass({
// When the panels are disabled, clicking on them results in a mouse event // 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 // which bubbles to certain elements in the tree. When this happens, close
// any settings page that is currently open (user/room/group). // any settings page that is currently open (user/room/group).
if (this.props.leftDisabled && if (this.props.leftDisabled && this.props.rightDisabled) {
this.props.rightDisabled && const targetClasses = new Set(ev.target.className.split(' '));
( if (
ev.target.className === 'mx_MatrixChat' || targetClasses.has('mx_MatrixChat') ||
ev.target.className === 'mx_MatrixChat_middlePanel' || targetClasses.has('mx_MatrixChat_middlePanel') ||
ev.target.className === 'mx_RoomView' targetClasses.has('mx_RoomView')
) ) {
) { dis.dispatch({ action: 'close_settings' });
dis.dispatch({ action: 'close_settings' }); }
} }
}, },
@ -286,6 +350,7 @@ const LoggedInView = React.createClass({
const NewVersionBar = sdk.getComponent('globals.NewVersionBar'); const NewVersionBar = sdk.getComponent('globals.NewVersionBar');
const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar'); const UpdateCheckBar = sdk.getComponent('globals.UpdateCheckBar');
const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar'); const PasswordNagBar = sdk.getComponent('globals.PasswordNagBar');
const ServerLimitBar = sdk.getComponent('globals.ServerLimitBar');
let page_element; let page_element;
let right_panel = ''; let right_panel = '';
@ -368,9 +433,26 @@ const LoggedInView = React.createClass({
break; 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; let topBar;
const isGuest = this.props.matrixClient.isGuest(); 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 this.props.config.piwik
) { ) {
const policyUrl = this.props.config.piwik.policyUrl || null; const policyUrl = this.props.config.piwik.policyUrl || null;

View file

@ -23,7 +23,7 @@ import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics"; import Analytics from "../../Analytics";
import DecryptionFailureTracker from "../../DecryptionFailureTracker"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
import MatrixClientPeg from "../../MatrixClientPeg"; import MatrixClientPeg from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
@ -45,6 +45,8 @@ import createRoom from "../../createRoom";
import KeyRequestHandler from '../../KeyRequestHandler'; import KeyRequestHandler from '../../KeyRequestHandler';
import { _t, getCurrentLanguage } from '../../languageHandler'; import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import { startAnyRegistrationFlow } from "../../Registration.js";
import { messageForSyncError } from '../../utils/ErrorUtils';
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
const VIEWS = { const VIEWS = {
@ -178,6 +180,8 @@ export default React.createClass({
// When showing Modal dialogs we need to set aria-hidden on the root app element // When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs // and disable it when there are no dialogs
hideToSRUsers: false, hideToSRUsers: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
}; };
return s; return s;
}, },
@ -282,6 +286,14 @@ export default React.createClass({
register_hs_url: paramHs, register_hs_url: paramHs,
}); });
} }
// Set a default IS with query param `is_url`
const paramIs = this.props.startingFragmentQueryParams.is_url;
if (paramIs) {
console.log('Setting register_is_url ', paramIs);
this.setState({
register_is_url: paramIs,
});
}
// a thing to call showScreen with once login completes. this is kept // a thing to call showScreen with once login completes. this is kept
// outside this.state because updating it should never trigger a // outside this.state because updating it should never trigger a
@ -471,7 +483,7 @@ export default React.createClass({
action: 'do_after_sync_prepared', action: 'do_after_sync_prepared',
deferred_action: payload, deferred_action: payload,
}); });
dis.dispatch({action: 'view_set_mxid'}); dis.dispatch({action: 'require_registration'});
return; return;
} }
@ -479,7 +491,11 @@ export default React.createClass({
case 'logout': case 'logout':
Lifecycle.logout(); Lifecycle.logout();
break; break;
case 'require_registration':
startAnyRegistrationFlow(payload);
break;
case 'start_registration': case 'start_registration':
// This starts the full registration flow
this._startRegistration(payload.params || {}); this._startRegistration(payload.params || {});
break; break;
case 'start_login': case 'start_login':
@ -945,7 +961,7 @@ export default React.createClass({
}); });
} }
dis.dispatch({ dis.dispatch({
action: 'view_set_mxid', action: 'require_registration',
// If the set_mxid dialog is cancelled, view /home because if the browser // If the set_mxid dialog is cancelled, view /home because if the browser
// was pointing at /user/@someone:domain?action=chat, the URL needs to be // was pointing at /user/@someone:domain?action=chat, the URL needs to be
// reset so that they can revisit /user/.. // (and trigger // reset so that they can revisit /user/.. // (and trigger
@ -1132,7 +1148,7 @@ export default React.createClass({
* *
* @param {string} teamToken * @param {string} teamToken
*/ */
_onLoggedIn: function(teamToken) { _onLoggedIn: async function(teamToken) {
this.setState({ this.setState({
view: VIEWS.LOGGED_IN, view: VIEWS.LOGGED_IN,
}); });
@ -1145,12 +1161,17 @@ export default React.createClass({
this._is_registered = false; this._is_registered = false;
if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) { if (this.props.config.welcomeUserId && getCurrentLanguage().startsWith("en")) {
createRoom({ const roomId = await createRoom({
dmUserId: this.props.config.welcomeUserId, dmUserId: this.props.config.welcomeUserId,
// Only view the welcome user if we're NOT looking at a room // Only view the welcome user if we're NOT looking at a room
andView: !this.state.currentRoomId, andView: !this.state.currentRoomId,
}); });
return; // if successful, return because we're already
// viewing the welcomeUserId room
// else, if failed, fall through to view_home_page
if (roomId) {
return;
}
} }
// The user has just logged in after registering // The user has just logged in after registering
dis.dispatch({action: 'view_home_page'}); dis.dispatch({action: 'view_home_page'});
@ -1232,13 +1253,20 @@ export default React.createClass({
return self._loggedInView.child.canResetTimelineInRoom(roomId); return self._loggedInView.child.canResetTimelineInRoom(roomId);
}); });
cli.on('sync', function(state, prevState) { cli.on('sync', function(state, prevState, data) {
// LifecycleStore and others cannot directly subscribe to matrix client for // LifecycleStore and others cannot directly subscribe to matrix client for
// events because flux only allows store state changes during flux dispatches. // events because flux only allows store state changes during flux dispatches.
// So dispatch directly from here. Ideally we'd use a SyncStateStore that // So dispatch directly from here. Ideally we'd use a SyncStateStore that
// would do this dispatch and expose the sync state itself (by listening to // would do this dispatch and expose the sync state itself (by listening to
// its own dispatch). // its own dispatch).
dis.dispatch({action: 'sync_state', prevState, state}); dis.dispatch({action: 'sync_state', prevState, state});
if (state === "ERROR" || state === "RECONNECTING") {
self.setState({syncError: data.error || true});
} else if (self.state.syncError) {
self.setState({syncError: null});
}
self.updateStatusIndicator(state, prevState); self.updateStatusIndicator(state, prevState);
if (state === "SYNCING" && prevState === "SYNCING") { if (state === "SYNCING" && prevState === "SYNCING") {
return; return;
@ -1262,6 +1290,7 @@ export default React.createClass({
}, true); }, true);
}); });
cli.on('Session.logged_out', function(call) { cli.on('Session.logged_out', function(call) {
if (Lifecycle.isLoggingOut()) return;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Signed out', '', ErrorDialog, { Modal.createTrackedDialog('Signed out', '', ErrorDialog, {
title: _t('Signed Out'), title: _t('Signed Out'),
@ -1304,9 +1333,20 @@ export default React.createClass({
} }
}); });
const dft = new DecryptionFailureTracker((failure) => { const dft = new DecryptionFailureTracker((total, errorCode) => {
// TODO: Pass reason for failure as third argument to trackEvent Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total);
Analytics.trackEvent('E2E', 'Decryption failure'); }, (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 // Shelved for later date when we have time to think about persisting history of
@ -1317,7 +1357,7 @@ export default React.createClass({
// When logging out, stop tracking failures and destroy state // When logging out, stop tracking failures and destroy state
cli.on("Session.logged_out", () => dft.stop()); cli.on("Session.logged_out", () => dft.stop());
cli.on("Event.decrypted", (e) => dft.eventDecrypted(e)); cli.on("Event.decrypted", (e, err) => dft.eventDecrypted(e, err));
const krh = new KeyRequestHandler(cli); const krh = new KeyRequestHandler(cli);
cli.on("crypto.roomKeyRequest", (req) => { cli.on("crypto.roomKeyRequest", (req) => {
@ -1406,7 +1446,7 @@ export default React.createClass({
} else if (screen == 'start') { } else if (screen == 'start') {
this.showScreen('home'); this.showScreen('home');
dis.dispatch({ dis.dispatch({
action: 'view_set_mxid', action: 'require_registration',
}); });
} else if (screen == 'directory') { } else if (screen == 'directory') {
dis.dispatch({ dis.dispatch({
@ -1722,8 +1762,15 @@ export default React.createClass({
} else { } else {
// we think we are logged in, but are still waiting for the /sync to complete // we think we are logged in, but are still waiting for the /sync to complete
const Spinner = sdk.getComponent('elements.Spinner'); const Spinner = sdk.getComponent('elements.Spinner');
let errorBox;
if (this.state.syncError) {
errorBox = <div className="mx_MatrixChat_syncError">
{messageForSyncError(this.state.syncError)}
</div>;
}
return ( return (
<div className="mx_MatrixChat_splash"> <div className="mx_MatrixChat_splash">
{errorBox}
<Spinner /> <Spinner />
<a href="#" className="mx_MatrixChat_splashButtons" onClick={this.onLogoutClick}> <a href="#" className="mx_MatrixChat_splashButtons" onClick={this.onLogoutClick}>
{ _t('Logout') } { _t('Logout') }

View file

@ -160,7 +160,7 @@ module.exports = React.createClass({
onInviteButtonClick: function() { onInviteButtonClick: function() {
if (this.context.matrixClient.isGuest()) { if (this.context.matrixClient.isGuest()) {
dis.dispatch({action: 'view_set_mxid'}); dis.dispatch({action: 'require_registration'});
return; return;
} }
@ -186,6 +186,9 @@ module.exports = React.createClass({
}, },
onRoomStateMember: function(ev, state, member) { onRoomStateMember: function(ev, state, member) {
if (member.roomId !== this.props.roomId) {
return;
}
// redraw the badge on the membership list // redraw the badge on the membership list
if (this.state.phase === this.Phase.RoomMemberList && member.roomId === this.props.roomId) { if (this.state.phase === this.Phase.RoomMemberList && member.roomId === this.props.roomId) {
this._delayedUpdate(); this._delayedUpdate();
@ -280,7 +283,7 @@ module.exports = React.createClass({
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
let isUserInRoom; let isUserInRoom;
if (room) { if (room) {
const numMembers = room.getJoinedMembers().length; const numMembers = room.getJoinedMemberCount();
membersTitle = _t('%(count)s Members', { count: numMembers }); membersTitle = _t('%(count)s Members', { count: numMembers });
membersBadge = <div title={membersTitle}>{ formatCount(numMembers) }</div>; membersBadge = <div title={membersTitle}>{ formatCount(numMembers) }</div>;
isUserInRoom = room.hasMembershipState(this.context.matrixClient.credentials.userId, 'join'); isUserInRoom = room.hasMembershipState(this.context.matrixClient.credentials.userId, 'join');

View file

@ -354,7 +354,7 @@ module.exports = React.createClass({
// to the directory. // to the directory.
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
if (!room.world_readable && !room.guest_can_join) { if (!room.world_readable && !room.guest_can_join) {
dis.dispatch({action: 'view_set_mxid'}); dis.dispatch({action: 'require_registration'});
return; return;
} }
} }

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -18,13 +18,15 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import { _t } from '../../languageHandler'; import { _t, _td } from '../../languageHandler';
import sdk from '../../index'; import sdk from '../../index';
import WhoIsTyping from '../../WhoIsTyping'; import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar'; import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend'; import Resend from '../../Resend';
import * as cryptodevices from '../../cryptodevices'; import * as cryptodevices from '../../cryptodevices';
import dis from '../../dispatcher';
import { messageForResourceLimitError } from '../../utils/ErrorUtils';
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
@ -106,6 +108,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
syncState: MatrixClientPeg.get().getSyncState(), syncState: MatrixClientPeg.get().getSyncState(),
syncStateData: MatrixClientPeg.get().getSyncStateData(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(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") { if (state === "SYNCING" && prevState === "SYNCING") {
return; return;
} }
this.setState({ this.setState({
syncState: state, syncState: state,
syncStateData: data,
}); });
}, },
@ -157,10 +161,12 @@ module.exports = React.createClass({
_onResendAllClick: function() { _onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room); Resend.resendUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
}, },
_onCancelAllClick: function() { _onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room); Resend.cancelUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'});
}, },
_onShowDevicesClick: function() { _onShowDevicesClick: function() {
@ -188,7 +194,7 @@ module.exports = React.createClass({
// changed - so we use '0' to indicate normal size, and other values to // changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes. // indicate other sizes.
_getSize: function() { _getSize: function() {
if (this.state.syncState === "ERROR" || if (this._shouldShowConnectionError() ||
(this.state.usersTyping.length > 0) || (this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages || this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline || !this.props.atEndOfLiveTimeline ||
@ -235,7 +241,7 @@ module.exports = React.createClass({
); );
} }
if (this.state.syncState === "ERROR") { if (this._shouldShowConnectionError()) {
return null; return null;
} }
@ -282,6 +288,21 @@ module.exports = React.createClass({
return avatars; 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() { _getUnsentMessageContent: function() {
const unsentMessages = this.state.unsentMessages; const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null; if (!unsentMessages.length) return null;
@ -305,7 +326,43 @@ module.exports = React.createClass({
}, },
); );
} else { } 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.length === 1 &&
unsentMessages[0].error && unsentMessages[0].error &&
unsentMessages[0].error.data && unsentMessages[0].error.data &&
@ -329,11 +386,13 @@ module.exports = React.createClass({
return <div className="mx_RoomStatusBar_connectionLostBar"> return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} /> <img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title"> <div>
{ title } <div className="mx_RoomStatusBar_connectionLostBar_title">
</div> { title }
<div className="mx_RoomStatusBar_connectionLostBar_desc"> </div>
{ content } <div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div> </div>
</div>; </div>;
}, },
@ -342,19 +401,17 @@ module.exports = React.createClass({
_getContent: function() { _getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
// no conn bar trumps unread count since you can't get unread messages if (this._shouldShowConnectionError()) {
// 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") {
return ( return (
<div className="mx_RoomStatusBar_connectionLostBar"> <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " /> <img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title"> <div>
{ _t('Connectivity to the server has been lost.') } <div className="mx_RoomStatusBar_connectionLostBar_title">
</div> { _t('Connectivity to the server has been lost.') }
<div className="mx_RoomStatusBar_connectionLostBar_desc"> </div>
{ _t('Sent messages will be stored until your connection has returned.') } <div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _t('Sent messages will be stored until your connection has returned.') }
</div>
</div> </div>
</div> </div>
); );

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2017 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,56 +16,53 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict'; import React from 'react';
import classNames from 'classnames';
var React = require('react'); import sdk from '../../index';
var ReactDOM = require('react-dom');
var classNames = require('classnames');
var sdk = require('../../index');
import { Droppable } from 'react-beautiful-dnd'; import { Droppable } from 'react-beautiful-dnd';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
var dis = require('../../dispatcher'); import dis from '../../dispatcher';
var Unread = require('../../Unread'); import Unread from '../../Unread';
var MatrixClientPeg = require('../../MatrixClientPeg'); import * as RoomNotifs from '../../RoomNotifs';
var RoomNotifs = require('../../RoomNotifs'); import * as FormattingUtils from '../../utils/FormattingUtils';
var FormattingUtils = require('../../utils/FormattingUtils');
var AccessibleButton = require('../../components/views/elements/AccessibleButton');
import Modal from '../../Modal';
import { KeyCode } from '../../Keyboard'; import { KeyCode } from '../../Keyboard';
import { Group } from 'matrix-js-sdk';
import PropTypes from 'prop-types';
// turn this on for drop & drag console debugging galore // turn this on for drop & drag console debugging galore
var debug = false; const debug = false;
const TRUNCATE_AT = 10; const TRUNCATE_AT = 10;
var RoomSubList = React.createClass({ const RoomSubList = React.createClass({
displayName: 'RoomSubList', displayName: 'RoomSubList',
debug: debug, debug: debug,
propTypes: { propTypes: {
list: React.PropTypes.arrayOf(React.PropTypes.object).isRequired, list: PropTypes.arrayOf(PropTypes.object).isRequired,
label: React.PropTypes.string.isRequired, label: PropTypes.string.isRequired,
tagName: React.PropTypes.string, tagName: PropTypes.string,
editable: React.PropTypes.bool, 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 // 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, startAsHidden: PropTypes.bool,
showSpinner: React.PropTypes.bool, // true to show a spinner if 0 elements when expanded showSpinner: PropTypes.bool, // true to show a spinner if 0 elements when expanded
collapsed: React.PropTypes.bool.isRequired, // is LeftPanel collapsed? collapsed: PropTypes.bool.isRequired, // is LeftPanel collapsed?
onHeaderClick: React.PropTypes.func, onHeaderClick: PropTypes.func,
alwaysShowHeader: React.PropTypes.bool, alwaysShowHeader: PropTypes.bool,
incomingCall: React.PropTypes.object, incomingCall: PropTypes.object,
onShowMoreRooms: React.PropTypes.func, onShowMoreRooms: PropTypes.func,
searchFilter: React.PropTypes.string, searchFilter: PropTypes.string,
emptyContent: React.PropTypes.node, // content shown if the list is empty emptyContent: PropTypes.node, // content shown if the list is empty
headerItems: React.PropTypes.node, // content shown in the sublist header headerItems: PropTypes.node, // content shown in the sublist header
extraTiles: React.PropTypes.arrayOf(React.PropTypes.node), // extra elements added beneath tiles extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles
showEmpty: PropTypes.bool,
}, },
getInitialState: function() { getInitialState: function() {
@ -77,10 +75,13 @@ var RoomSubList = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
onHeaderClick: function() {}, // NOP onHeaderClick: function() {
onShowMoreRooms: function() {}, // NOP }, // NOP
onShowMoreRooms: function() {
}, // NOP
extraTiles: [], extraTiles: [],
isInvite: false, isInvite: false,
showEmpty: true,
}; };
}, },
@ -115,7 +116,7 @@ var RoomSubList = React.createClass({
// The header is collapsable if it is hidden or not stuck // The header is collapsable if it is hidden or not stuck
// The dataset elements are added in the RoomList _initAndPositionStickyHeaders method // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method
isCollapsableOnClick: function() { isCollapsableOnClick: function() {
var stuck = this.refs.header.dataset.stuck; const stuck = this.refs.header.dataset.stuck;
if (this.state.hidden || stuck === undefined || stuck === "none") { if (this.state.hidden || stuck === undefined || stuck === "none") {
return true; return true;
} else { } else {
@ -141,12 +142,12 @@ var RoomSubList = React.createClass({
onClick: function(ev) { onClick: function(ev) {
if (this.isCollapsableOnClick()) { if (this.isCollapsableOnClick()) {
// The header isCollapsable, so the click is to be interpreted as collapse and truncation logic // The header isCollapsable, so the click is to be interpreted as collapse and truncation logic
var isHidden = !this.state.hidden; const isHidden = !this.state.hidden;
this.setState({ hidden : isHidden }); this.setState({hidden: isHidden});
if (isHidden) { if (isHidden) {
// as good a way as any to reset the truncate state // as good a way as any to reset the truncate state
this.setState({ truncateAt : TRUNCATE_AT }); this.setState({truncateAt: TRUNCATE_AT});
} }
this.props.onShowMoreRooms(); this.props.onShowMoreRooms();
@ -161,7 +162,7 @@ var RoomSubList = React.createClass({
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: roomId, room_id: roomId,
clear_search: (ev && (ev.keyCode == KeyCode.ENTER || ev.keyCode == KeyCode.SPACE)), clear_search: (ev && (ev.keyCode === KeyCode.ENTER || ev.keyCode === KeyCode.SPACE)),
}); });
}, },
@ -171,17 +172,17 @@ var RoomSubList = React.createClass({
}, },
_shouldShowMentionBadge: function(roomNotifState) { _shouldShowMentionBadge: function(roomNotifState) {
return roomNotifState != RoomNotifs.MUTE; return roomNotifState !== RoomNotifs.MUTE;
}, },
/** /**
* Total up all the notification counts from the rooms * Total up all the notification counts from the rooms
* *
* @param {Number} If supplied will only total notifications for rooms outside the truncation number * @param {Number} truncateAt If supplied will only total notifications for rooms outside the truncation number
* @returns {Array} The array takes the form [total, highlight] where highlight is a bool * @returns {Array} The array takes the form [total, highlight] where highlight is a bool
*/ */
roomNotificationCount: function(truncateAt) { roomNotificationCount: function(truncateAt) {
var self = this; const self = this;
if (this.props.isInvite) { if (this.props.isInvite) {
return [0, true]; return [0, true];
@ -189,9 +190,9 @@ var RoomSubList = React.createClass({
return this.props.list.reduce(function(result, room, index) { return this.props.list.reduce(function(result, room, index) {
if (truncateAt === undefined || index >= truncateAt) { if (truncateAt === undefined || index >= truncateAt) {
var roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId); const roomNotifState = RoomNotifs.getRoomNotifsState(room.roomId);
var highlight = room.getUnreadNotificationCount('highlight') > 0; const highlight = room.getUnreadNotificationCount('highlight') > 0;
var notificationCount = room.getUnreadNotificationCount(); const notificationCount = room.getUnreadNotificationCount();
const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState); const notifBadges = notificationCount > 0 && self._shouldShowNotifBadge(roomNotifState);
const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState); const mentionBadges = highlight && self._shouldShowMentionBadge(roomNotifState);
@ -240,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() { _getHeaderJsx: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const subListNotifications = this.roomNotificationCount();
const subListNotifCount = subListNotifications[0];
const subListNotifHighlight = subListNotifications[1];
var subListNotifications = this.roomNotificationCount(); const totalTiles = this.props.list.length + (this.props.extraTiles || []).length;
var subListNotifCount = subListNotifications[0]; const roomCount = totalTiles > 0 ? totalTiles : '';
var subListNotifHighlight = subListNotifications[1];
var totalTiles = this.props.list.length + (this.props.extraTiles || []).length; const chevronClasses = classNames({
var roomCount = totalTiles > 0 ? totalTiles : '';
var chevronClasses = classNames({
'mx_RoomSubList_chevron': true, 'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': this.state.hidden, 'mx_RoomSubList_chevronRight': this.state.hidden,
'mx_RoomSubList_chevronDown': !this.state.hidden, 'mx_RoomSubList_chevronDown': !this.state.hidden,
}); });
var badgeClasses = classNames({ const badgeClasses = classNames({
'mx_RoomSubList_badge': true, 'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': subListNotifHighlight, 'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
}); });
var badge; let badge;
if (subListNotifCount > 0) { if (subListNotifCount > 0) {
badge = <div className={badgeClasses}>{ FormattingUtils.formatCount(subListNotifCount) }</div>; badge = <div className={badgeClasses} onClick={this._onNotifBadgeClick}>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>;
} else if (this.props.isInvite) { } else if (this.props.isInvite) {
// no notifications but highlight anyway because this is an invite badge // no notifications but highlight anyway because this is an invite badge
badge = <div className={badgeClasses}>!</div>; badge = <div className={badgeClasses} onClick={this._onInviteBadgeClick}>!</div>;
} }
// When collapsed, allow a long hover on the header to show user // When collapsed, allow a long hover on the header to show user
// the full tag name and room count // the full tag name and room count
var title; let title;
if (this.props.collapsed) { if (this.props.collapsed) {
title = this.props.label; title = this.props.label;
if (roomCount !== '') { if (roomCount !== '') {
@ -279,63 +325,66 @@ var RoomSubList = React.createClass({
} }
} }
var incomingCall; let incomingCall;
if (this.props.incomingCall) { if (this.props.incomingCall) {
var self = this; const self = this;
// Check if the incoming call is for this section // Check if the incoming call is for this section
var incomingCallRoom = this.props.list.filter(function(room) { const incomingCallRoom = this.props.list.filter(function(room) {
return self.props.incomingCall.roomId === room.roomId; return self.props.incomingCall.roomId === room.roomId;
}); });
if (incomingCallRoom.length === 1) { if (incomingCallRoom.length === 1) {
var IncomingCallBox = sdk.getComponent("voip.IncomingCallBox"); const IncomingCallBox = sdk.getComponent("voip.IncomingCallBox");
incomingCall = <IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={ this.props.incomingCall }/>; incomingCall =
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
} }
} }
var tabindex = this.props.searchFilter === "" ? "0" : "-1"; const tabindex = this.props.searchFilter === "" ? "0" : "-1";
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return ( return (
<div className="mx_RoomSubList_labelContainer" title={ title } ref="header"> <div className="mx_RoomSubList_labelContainer" title={title} ref="header">
<AccessibleButton onClick={ this.onClick } className="mx_RoomSubList_label" tabIndex={tabindex}> <AccessibleButton onClick={this.onClick} className="mx_RoomSubList_label" tabIndex={tabindex}>
{ this.props.collapsed ? '' : this.props.label } {this.props.collapsed ? '' : this.props.label}
<div className="mx_RoomSubList_roomCount">{ roomCount }</div> <div className="mx_RoomSubList_roomCount">{roomCount}</div>
<div className={chevronClasses}></div> <div className={chevronClasses} />
{ badge } {badge}
{ incomingCall } {incomingCall}
</AccessibleButton> </AccessibleButton>
</div> </div>
); );
}, },
_createOverflowTile: function(overflowCount, totalCount) { _createOverflowTile: function(overflowCount, totalCount) {
var content = <div className="mx_RoomSubList_chevronDown"></div>; let content = <div className="mx_RoomSubList_chevronDown" />;
var overflowNotifications = this.roomNotificationCount(TRUNCATE_AT); const overflowNotifications = this.roomNotificationCount(TRUNCATE_AT);
var overflowNotifCount = overflowNotifications[0]; const overflowNotifCount = overflowNotifications[0];
var overflowNotifHighlight = overflowNotifications[1]; const overflowNotifHighlight = overflowNotifications[1];
if (overflowNotifCount && !this.props.collapsed) { if (overflowNotifCount && !this.props.collapsed) {
content = FormattingUtils.formatCount(overflowNotifCount); content = FormattingUtils.formatCount(overflowNotifCount);
} }
var badgeClasses = classNames({ const badgeClasses = classNames({
'mx_RoomSubList_moreBadge': true, 'mx_RoomSubList_moreBadge': true,
'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed, 'mx_RoomSubList_moreBadgeNotify': overflowNotifCount && !this.props.collapsed,
'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed, 'mx_RoomSubList_moreBadgeHighlight': overflowNotifHighlight && !this.props.collapsed,
}); });
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return ( return (
<AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}> <AccessibleButton className="mx_RoomSubList_ellipsis" onClick={this._showFullMemberList}>
<div className="mx_RoomSubList_line"></div> <div className="mx_RoomSubList_line" />
<div className="mx_RoomSubList_more">{ _t("more") }</div> <div className="mx_RoomSubList_more">{_t("more")}</div>
<div className={ badgeClasses }>{ content }</div> <div className={badgeClasses}>{content}</div>
</AccessibleButton> </AccessibleButton>
); );
}, },
_showFullMemberList: function() { _showFullMemberList: function() {
this.setState({ this.setState({
truncateAt: -1 truncateAt: -1,
}); });
this.props.onShowMoreRooms(); this.props.onShowMoreRooms();
@ -343,37 +392,51 @@ var RoomSubList = React.createClass({
}, },
render: function() { render: function() {
var connectDropTarget = this.props.connectDropTarget; const TruncatedList = sdk.getComponent('elements.TruncatedList');
var TruncatedList = sdk.getComponent('elements.TruncatedList');
var label = this.props.collapsed ? null : this.props.label;
let content; let content;
if (this.state.sortedList.length === 0 && !this.props.searchFilter && this.props.extraTiles.length === 0) {
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 { } else {
content = this.makeRoomTiles(); if (this.state.sortedList.length === 0 && this.props.extraTiles.length === 0) {
content.push(...this.props.extraTiles); // 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) { if (this.state.sortedList.length > 0 || this.props.extraTiles.length > 0 || this.props.editable) {
var subList; let subList;
var classes = "mx_RoomSubList"; const classes = "mx_RoomSubList";
if (!this.state.hidden) { if (!this.state.hidden) {
subList = <TruncatedList className={ classes } truncateAt={this.state.truncateAt} subList = <TruncatedList className={classes} truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile} > createOverflowElement={this._createOverflowTile}>
{ content } {content}
</TruncatedList>; </TruncatedList>;
} } else {
else { subList = <TruncatedList className={classes}>
subList = <TruncatedList className={ classes }> </TruncatedList>;
</TruncatedList>;
} }
const subListContent = <div> const subListContent = <div>
{ this._getHeaderJsx() } {this._getHeaderJsx()}
{ subList } {subList}
</div>; </div>;
return this.props.editable ? return this.props.editable ?
@ -381,23 +444,26 @@ var RoomSubList = React.createClass({
droppableId={"room-sub-list-droppable_" + this.props.tagName} droppableId={"room-sub-list-droppable_" + this.props.tagName}
type="draggable-RoomTile" type="draggable-RoomTile"
> >
{ (provided, snapshot) => ( {(provided, snapshot) => (
<div ref={provided.innerRef}> <div ref={provided.innerRef}>
{ subListContent } {subListContent}
</div> </div>
) } )}
</Droppable> : subListContent; </Droppable> : subListContent;
} } else {
else { const Loader = sdk.getComponent("elements.Spinner");
var Loader = sdk.getComponent("elements.Spinner"); if (this.props.showSpinner) {
content = <Loader />;
}
return ( return (
<div className="mx_RoomSubList"> <div className="mx_RoomSubList">
{ this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined } {this.props.alwaysShowHeader ? this._getHeaderJsx() : undefined}
{ (this.props.showSpinner && !this.state.hidden) ? <Loader /> : undefined } { this.state.hidden ? undefined : content }
</div> </div>
); );
} }
} },
}); });
module.exports = RoomSubList; module.exports = RoomSubList;

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -44,7 +45,9 @@ import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import WidgetUtils from '../../utils/WidgetUtils';
const DEBUG = false; const DEBUG = false;
let debuglog = function() {}; let debuglog = function() {};
@ -88,13 +91,16 @@ module.exports = React.createClass({
}, },
getInitialState: function() { getInitialState: function() {
const llMembers = MatrixClientPeg.get().hasLazyLoadMembersEnabled();
return { return {
room: null, room: null,
roomId: null, roomId: null,
roomLoading: true, roomLoading: true,
peekLoading: false, peekLoading: false,
shouldPeek: true, shouldPeek: true,
// used to trigger a rerender in TimelinePanel once the members are loaded,
// so RR are rendered again (now with the members available), ...
membersLoaded: !llMembers,
// The event to be scrolled to initially // The event to be scrolled to initially
initialEventId: null, initialEventId: null,
// The offset in pixels from the event with which to scroll vertically // The offset in pixels from the event with which to scroll vertically
@ -145,12 +151,14 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership); MatrixClientPeg.get().on("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("accountData", this.onAccountData);
// Start listening for RoomViewStore updates // Start listening for RoomViewStore updates
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
this._onRoomViewStoreUpdate(true); this._onRoomViewStoreUpdate(true);
WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate);
}, },
_onRoomViewStoreUpdate: function(initial) { _onRoomViewStoreUpdate: function(initial) {
@ -241,6 +249,12 @@ module.exports = React.createClass({
} }
}, },
_onWidgetEchoStoreUpdate: function() {
this.setState({
showApps: this._shouldShowApps(this.state.room),
});
},
_setupRoom: function(room, roomId, joining, shouldPeek) { _setupRoom: function(room, roomId, joining, shouldPeek) {
// if this is an unknown room then we're in one of three states: // if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can peek into (search engine) (we can /peek)
@ -297,11 +311,13 @@ module.exports = React.createClass({
throw err; throw err;
} }
}); });
} else if (room) {
//viewing a previously joined room, try to lazy load members
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.setState({isPeeking: false});
} }
} else if (room) {
// Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking();
this.setState({isPeeking: false});
} }
}, },
@ -317,14 +333,9 @@ module.exports = React.createClass({
return false; return false;
} }
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); const widgets = WidgetEchoStore.getEchoedRoomWidgets(room.roomId, WidgetUtils.getRoomWidgets(room));
// any valid widget = show apps
for (let i = 0; i < appsStateEvents.length; i++) { return widgets.length > 0 || WidgetEchoStore.roomHasPendingWidgets(room.roomId, WidgetUtils.getRoomWidgets(room));
if (appsStateEvents[i].getContent().type && appsStateEvents[i].getContent().url) {
return true;
}
}
return false;
}, },
componentDidMount: function() { componentDidMount: function() {
@ -345,7 +356,7 @@ module.exports = React.createClass({
// XXX: EVIL HACK to autofocus inviting on empty rooms. // XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer. // We use the setTimeout to avoid racing with focus_composer.
if (this.state.room && if (this.state.room &&
this.state.room.getJoinedMembers().length == 1 && this.state.room.getJoinedMemberCount() == 1 &&
this.state.room.getLiveTimeline() && this.state.room.getLiveTimeline() &&
this.state.room.getLiveTimeline().getEvents() && this.state.room.getLiveTimeline().getEvents() &&
this.state.room.getLiveTimeline().getEvents().length <= 6) { this.state.room.getLiveTimeline().getEvents().length <= 6) {
@ -404,8 +415,8 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("Room.myMembership", this.onMyMembership);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("RoomMember.membership", this.onRoomMemberMembership);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData); MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
} }
@ -419,6 +430,8 @@ module.exports = React.createClass({
this._roomStoreToken.remove(); this._roomStoreToken.remove();
} }
WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate);
// cancel any pending calls to the rate_limited_funcs // cancel any pending calls to the rate_limited_funcs
this._updateRoomMembers.cancelPendingCall(); this._updateRoomMembers.cancelPendingCall();
@ -572,6 +585,27 @@ module.exports = React.createClass({
this._warnAboutEncryption(room); this._warnAboutEncryption(room);
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
this._loadMembersIfJoined(room);
},
_loadMembersIfJoined: async function(room) {
// lazy load members if enabled
const cli = MatrixClientPeg.get();
if (cli.hasLazyLoadMembersEnabled()) {
if (room && room.getMyMembership() === 'join') {
try {
await room.loadMembersIfNeeded();
if (!this.unmounted) {
this.setState({membersLoaded: true});
}
} catch (err) {
const errorMessage = `Fetching room members for ${room.roomId} failed.` +
" Room members will appear incomplete.";
console.error(errorMessage);
console.error(err);
}
}
}
}, },
_warnAboutEncryption: function(room) { _warnAboutEncryption: function(room) {
@ -618,9 +652,11 @@ module.exports = React.createClass({
} }
}, },
_updatePreviewUrlVisibility: function(room) { _updatePreviewUrlVisibility: function({roomId}) {
// URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit
const key = MatrixClientPeg.get().isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled';
this.setState({ this.setState({
showUrlPreview: SettingsStore.getValue("urlPreviewsEnabled", room.roomId), showUrlPreview: SettingsStore.getValue(key, roomId),
}); });
}, },
@ -645,19 +681,23 @@ module.exports = React.createClass({
}, },
onAccountData: function(event) { onAccountData: function(event) {
if (event.getType() === "org.matrix.preview_urls" && this.state.room) { const type = event.getType();
if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) {
// non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls`
this._updatePreviewUrlVisibility(this.state.room); this._updatePreviewUrlVisibility(this.state.room);
} }
}, },
onRoomAccountData: function(event, room) { onRoomAccountData: function(event, room) {
if (room.roomId == this.state.roomId) { if (room.roomId == this.state.roomId) {
if (event.getType() === "org.matrix.room.color_scheme") { const type = event.getType();
if (type === "org.matrix.room.color_scheme") {
const color_scheme = event.getContent(); const color_scheme = event.getContent();
// XXX: we should validate the event // XXX: we should validate the event
console.log("Tinter.tint from onRoomAccountData"); console.log("Tinter.tint from onRoomAccountData");
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
} else if (event.getType() === "org.matrix.room.preview_urls") { } else if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") {
// non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls`
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
} }
} }
@ -675,12 +715,12 @@ module.exports = React.createClass({
} }
this._updateRoomMembers(); this._updateRoomMembers();
this._checkIfAlone(this.state.room);
}, },
onRoomMemberMembership: function(ev, member, oldMembership) { onMyMembership: function(room, membership, oldMembership) {
if (member.userId == MatrixClientPeg.get().credentials.userId) { if (room.roomId === this.state.roomId) {
this.forceUpdate(); this.forceUpdate();
this._loadMembersIfJoined(room);
} }
}, },
@ -691,6 +731,7 @@ module.exports = React.createClass({
// refresh the conf call notification state // refresh the conf call notification state
this._updateConfCallNotification(); this._updateConfCallNotification();
this._updateDMState(); this._updateDMState();
this._checkIfAlone(this.state.room);
}, 500), }, 500),
_checkIfAlone: function(room) { _checkIfAlone: function(room) {
@ -703,8 +744,8 @@ module.exports = React.createClass({
return; return;
} }
const joinedMembers = room.currentState.getMembers().filter((m) => m.membership === "join" || m.membership === "invite"); const joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount();
this.setState({isAlone: joinedMembers.length === 1}); this.setState({isAlone: joinedOrInvitedMemberCount === 1});
}, },
_updateConfCallNotification: function() { _updateConfCallNotification: function() {
@ -732,40 +773,13 @@ module.exports = React.createClass({
}, },
_updateDMState() { _updateDMState() {
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); const room = this.state.room;
if (!me || me.membership !== "join") { if (room.getMyMembership() != "join") {
return; return;
} }
const dmInviter = room.getDMInviter();
// The user may have accepted an invite with is_direct set if (dmInviter) {
if (me.events.member.getPrevContent().membership === "invite" && Rooms.setDMRoom(room.roomId, dmInviter);
me.events.member.getPrevContent().is_direct
) {
// This is a DM with the sender of the invite event (which we assume
// preceded the join event)
Rooms.setDMRoom(
this.state.room.roomId,
me.events.member.getUnsigned().prev_sender,
);
return;
}
const invitedMembers = this.state.room.getMembersWithMembership("invite");
const joinedMembers = this.state.room.getMembersWithMembership("join");
// There must be one invited member and one joined member
if (invitedMembers.length !== 1 || joinedMembers.length !== 1) {
return;
}
// The user may have sent an invite with is_direct sent
const other = invitedMembers[0];
if (other &&
other.membership === "invite" &&
other.events.member.getContent().is_direct
) {
Rooms.setDMRoom(this.state.room.roomId, other.userId);
return;
} }
}, },
@ -916,7 +930,7 @@ module.exports = React.createClass({
dis.dispatch({action: 'focus_composer'}); dis.dispatch({action: 'focus_composer'});
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'}); dis.dispatch({action: 'require_registration'});
return; return;
} }
@ -947,7 +961,7 @@ module.exports = React.createClass({
injectSticker: function(url, info, text) { injectSticker: function(url, info, text) {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'}); dis.dispatch({action: 'require_registration'});
return; return;
} }
@ -1462,6 +1476,7 @@ module.exports = React.createClass({
const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar");
if (!this.state.room) { if (!this.state.room) {
if (this.state.roomLoading || this.state.peekLoading) { if (this.state.roomLoading || this.state.peekLoading) {
@ -1508,9 +1523,8 @@ module.exports = React.createClass({
} }
} }
const myUserId = MatrixClientPeg.get().credentials.userId; const myMembership = this.state.room.getMyMembership();
const myMember = this.state.room.getMember(myUserId); if (myMembership == 'invite') {
if (myMember && myMember.membership == 'invite') {
if (this.state.joining || this.state.rejecting) { if (this.state.joining || this.state.rejecting) {
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
@ -1518,6 +1532,8 @@ module.exports = React.createClass({
</div> </div>
); );
} else { } else {
const myUserId = MatrixClientPeg.get().credentials.userId;
const myMember = this.state.room.getMember(myUserId);
const inviteEvent = myMember.events.member; const inviteEvent = myMember.events.member;
var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender(); var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
@ -1587,6 +1603,11 @@ module.exports = React.createClass({
/>; />;
} }
const showRoomUpgradeBar = (
this.state.room.shouldUpgradeToVersion() &&
this.state.room.userMayUpgradeRoom(MatrixClientPeg.get().credentials.userId)
);
let aux = null; let aux = null;
let hideCancel = false; let hideCancel = false;
if (this.state.editingRoomSettings) { if (this.state.editingRoomSettings) {
@ -1598,10 +1619,13 @@ module.exports = React.createClass({
} else if (this.state.searching) { } else if (this.state.searching) {
hideCancel = true; // has own cancel hideCancel = true; // has own cancel
aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} />; aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress} onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch} />;
} else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} />;
hideCancel = true;
} else if (this.state.showingPinned) { } else if (this.state.showingPinned) {
hideCancel = true; // has own cancel hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />; aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (!myMember || myMember.membership !== "join") { } else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it. // We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it. // We may have a 3rd party invite to it.
var inviterName = undefined; var inviterName = undefined;
@ -1643,7 +1667,7 @@ module.exports = React.createClass({
let messageComposer, searchInfo; let messageComposer, searchInfo;
const canSpeak = ( const canSpeak = (
// joined and not showing search results // joined and not showing search results
myMember && (myMember.membership == 'join') && !this.state.searchResults myMembership == 'join' && !this.state.searchResults
); );
if (canSpeak) { if (canSpeak) {
messageComposer = messageComposer =
@ -1744,6 +1768,7 @@ module.exports = React.createClass({
onReadMarkerUpdated={this._updateTopUnreadMessagesBar} onReadMarkerUpdated={this._updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview} showUrlPreview = {this.state.showUrlPreview}
className="mx_RoomView_messagePanel" className="mx_RoomView_messagePanel"
membersLoaded={this.state.membersLoaded}
/>); />);
let topUnreadMessagesBar = null; let topUnreadMessagesBar = null;
@ -1778,15 +1803,15 @@ module.exports = React.createClass({
oobData={this.props.oobData} oobData={this.props.oobData}
editing={this.state.editingRoomSettings} editing={this.state.editingRoomSettings}
saving={this.state.uploadingRoomSettings} saving={this.state.uploadingRoomSettings}
inRoom={myMember && myMember.membership === 'join'} inRoom={myMembership === 'join'}
collapsedRhs={this.props.collapsedRhs} collapsedRhs={this.props.collapsedRhs}
onSearchClick={this.onSearchClick} onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick} onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick} onPinnedClick={this.onPinnedClick}
onSaveClick={this.onSettingsSaveClick} onSaveClick={this.onSettingsSaveClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null} onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMember && myMember.membership === "leave") ? this.onForgetClick : null} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMember && myMember.membership === "join") ? this.onLeaveClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}
/> />
{ auxPanel } { auxPanel }
<div className={fadableSectionClasses}> <div className={fadableSectionClasses}>

View file

@ -76,7 +76,7 @@ const TagPanel = React.createClass({
_onClientSync(syncState, prevState) { _onClientSync(syncState, prevState) {
// Consider the client reconnected if there is no error with syncing. // Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING or PREPARED. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState; const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected) { if (reconnected) {
// Load joined groups // Load joined groups

View file

@ -1146,10 +1146,11 @@ var TimelinePanel = React.createClass({
// of paginating our way through the entire history of the room. // of paginating our way through the entire history of the room.
const stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); const stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS);
// If the state is PREPARED, we're still waiting for the js-sdk to sync with // If the state is PREPARED or CATCHUP, we're still waiting for the js-sdk to sync with
// the HS and fetch the latest events, so we are effectively forward paginating. // the HS and fetch the latest events, so we are effectively forward paginating.
const forwardPaginating = ( const forwardPaginating = (
this.state.forwardPaginating || this.state.clientSyncState == 'PREPARED' this.state.forwardPaginating ||
['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState)
); );
return ( return (
<MessagePanel ref="messagePanel" <MessagePanel ref="messagePanel"

View file

@ -81,6 +81,7 @@ const SIMPLE_SETTINGS = [
{ id: "VideoView.flipVideoHorizontally" }, { id: "VideoView.flipVideoHorizontally" },
{ id: "TagPanel.disableTagPanel" }, { id: "TagPanel.disableTagPanel" },
{ id: "enableWidgetScreenshots" }, { id: "enableWidgetScreenshots" },
{ id: "RoomSubList.showEmpty" },
]; ];
// These settings must be defined in SettingsStore // These settings must be defined in SettingsStore
@ -802,7 +803,7 @@ module.exports = React.createClass({
} }
return ( return (
<div> <div>
<h3>{ _t("Debug Logs Submission") }</h3> <h3>{ _t("Submit Debug Logs") }</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
<p>{ <p>{
_t( "If you've submitted a bug via GitHub, debug logs can help " + _t( "If you've submitted a bug via GitHub, debug logs can help " +
@ -843,8 +844,16 @@ module.exports = React.createClass({
SettingsStore.getLabsFeatures().forEach((featureId) => { SettingsStore.getLabsFeatures().forEach((featureId) => {
// TODO: this ought to be a separate component so that we don't need // TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render // to rebind the onChange each time we render
const onChange = (e) => { const onChange = async (e) => {
SettingsStore.setFeatureEnabled(featureId, e.target.checked); const checked = e.target.checked;
if (featureId === "feature_lazyloading") {
const confirmed = await this._onLazyLoadChanging(checked);
if (!confirmed) {
e.preventDefault();
return;
}
}
await SettingsStore.setFeatureEnabled(featureId, checked);
this.forceUpdate(); this.forceUpdate();
}; };
@ -854,7 +863,7 @@ module.exports = React.createClass({
type="checkbox" type="checkbox"
id={featureId} id={featureId}
name={featureId} name={featureId}
defaultChecked={SettingsStore.isFeatureEnabled(featureId)} checked={SettingsStore.isFeatureEnabled(featureId)}
onChange={onChange} onChange={onChange}
/> />
<label htmlFor={featureId}>{ SettingsStore.getDisplayName(featureId) }</label> <label htmlFor={featureId}>{ SettingsStore.getDisplayName(featureId) }</label>
@ -877,6 +886,30 @@ module.exports = React.createClass({
); );
}, },
_onLazyLoadChanging: async function(enabling) {
// don't prevent turning LL off when not supported
if (enabling) {
const supported = await MatrixClientPeg.get().doesServerSupportLazyLoading();
if (!supported) {
await new Promise((resolve) => {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: _t("Lazy loading members not supported"),
description:
<div>
{ _t("Lazy loading is not supported by your " +
"current homeserver.") }
</div>,
button: _t("OK"),
onFinished: resolve,
});
});
return false;
}
}
return true;
},
_renderDeactivateAccount: function() { _renderDeactivateAccount: function() {
return <div> return <div>
<h3>{ _t("Deactivate Account") }</h3> <h3>{ _t("Deactivate Account") }</h3>
@ -888,6 +921,25 @@ module.exports = React.createClass({
</div>; </div>;
}, },
_renderTermsAndConditionsLinks: function() {
if (SdkConfig.get().terms_and_conditions_links) {
const tncLinks = [];
for (const tncEntry of SdkConfig.get().terms_and_conditions_links) {
tncLinks.push(<div key={tncEntry.url}>
<a href={tncEntry.url} rel="noopener" target="_blank">{tncEntry.text}</a>
</div>);
}
return <div>
<h3>{ _t("Legal") }</h3>
<div className="mx_UserSettings_section">
{tncLinks}
</div>
</div>;
} else {
return null;
}
},
_renderClearCache: function() { _renderClearCache: function() {
return <div> return <div>
<h3>{ _t("Clear Cache") }</h3> <h3>{ _t("Clear Cache") }</h3>
@ -1374,6 +1426,8 @@ module.exports = React.createClass({
{ this._renderDeactivateAccount() } { this._renderDeactivateAccount() }
{ this._renderTermsAndConditionsLinks() }
</GeminiScrollbarWrapper> </GeminiScrollbarWrapper>
</div> </div>
); );

View file

@ -20,11 +20,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import Login from '../../../Login'; import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
// For validating phone numbers without country codes // For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -93,6 +94,13 @@ module.exports = React.createClass({
this._unmounted = true; this._unmounted = true;
}, },
onPasswordLoginError: function(errorText) {
this.setState({
errorText,
loginIncorrect: Boolean(errorText),
});
},
onPasswordLogin: function(username, phoneCountry, phoneNumber, password) { onPasswordLogin: function(username, phoneCountry, phoneNumber, password) {
this.setState({ this.setState({
busy: true, busy: true,
@ -114,6 +122,30 @@ module.exports = React.createClass({
const usingEmail = username.indexOf("@") > 0; const usingEmail = username.indexOf("@") > 0;
if (error.httpStatus === 400 && usingEmail) { if (error.httpStatus === 400 && usingEmail) {
errorText = _t('This Home Server does not support login using email address.'); errorText = _t('This Home Server does not support login using email address.');
} else if (error.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
const errorDetail = messageForResourceLimitError(
error.data.limit_type,
error.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
errorText = (
<div>
<div>{errorTop}</div>
<div className="mx_Login_smallError">{errorDetail}</div>
</div>
);
} else if (error.httpStatus === 401 || error.httpStatus === 403) { } else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (SdkConfig.get()['disable_custom_urls']) { if (SdkConfig.get()['disable_custom_urls']) {
errorText = ( errorText = (
@ -357,6 +389,7 @@ module.exports = React.createClass({
return ( return (
<PasswordLogin <PasswordLogin
onSubmit={this.onPasswordLogin} onSubmit={this.onPasswordLogin}
onError={this.onPasswordLoginError}
initialUsername={this.state.username} initialUsername={this.state.username}
initialPhoneCountry={this.state.phoneCountry} initialPhoneCountry={this.state.phoneCountry}
initialPhoneNumber={this.state.phoneNumber} initialPhoneNumber={this.state.phoneNumber}

View file

@ -26,9 +26,10 @@ import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import RegistrationForm from '../../views/login/RegistrationForm'; import RegistrationForm from '../../views/login/RegistrationForm';
import RtsClient from '../../../RtsClient'; import RtsClient from '../../../RtsClient';
import { _t } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
const MIN_PASSWORD_LENGTH = 6; const MIN_PASSWORD_LENGTH = 6;
@ -92,6 +93,7 @@ module.exports = React.createClass({
doingUIAuth: Boolean(this.props.sessionId), doingUIAuth: Boolean(this.props.sessionId),
hsUrl: this.props.customHsUrl, hsUrl: this.props.customHsUrl,
isUrl: this.props.customIsUrl, isUrl: this.props.customIsUrl,
flows: null,
}; };
}, },
@ -144,11 +146,27 @@ module.exports = React.createClass({
}); });
}, },
_replaceClient: function() { _replaceClient: async function() {
this._matrixClient = Matrix.createClient({ this._matrixClient = Matrix.createClient({
baseUrl: this.state.hsUrl, baseUrl: this.state.hsUrl,
idBaseUrl: this.state.isUrl, idBaseUrl: this.state.isUrl,
}); });
try {
await this._makeRegisterRequest({});
// This should never succeed since we specified an empty
// auth object.
console.log("Expecting 401 from register request but got success!");
} catch (e) {
if (e.httpStatus === 401) {
this.setState({
flows: e.data.flows,
});
} else {
this.setState({
errorText: _t("Unable to query for supported registration methods"),
});
}
}
}, },
onFormSubmit: function(formVals) { onFormSubmit: function(formVals) {
@ -164,7 +182,29 @@ module.exports = React.createClass({
if (!success) { if (!success) {
let msg = response.message || response.toString(); let msg = response.message || response.toString();
// can we give a better error message? // can we give a better error message?
if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { if (response.errcode == 'M_RESOURCE_LIMIT_EXCEEDED') {
const errorTop = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'monthly_active_user': _td(
"This homeserver has hit its Monthly Active User limit.",
),
'': _td(
"This homeserver has exceeded one of its resource limits.",
),
});
const errorDetail = messageForResourceLimitError(
response.data.limit_type,
response.data.admin_contact, {
'': _td(
"Please <a>contact your service administrator</a> to continue using this service.",
),
});
msg = <div>
<p>{errorTop}</p>
<p>{errorDetail}</p>
</div>;
} else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) {
let msisdnAvailable = false; let msisdnAvailable = false;
for (const flow of response.available_flows) { for (const flow of response.available_flows) {
msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1; msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1;
@ -281,6 +321,12 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_PHONE_NUMBER_INVALID": case "RegistrationForm.ERR_PHONE_NUMBER_INVALID":
errMsg = _t('This doesn\'t look like a valid phone number.'); errMsg = _t('This doesn\'t look like a valid phone number.');
break; break;
case "RegistrationForm.ERR_MISSING_EMAIL":
errMsg = _t('An email address is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_MISSING_PHONE_NUMBER":
errMsg = _t('A phone number is required to register on this homeserver.');
break;
case "RegistrationForm.ERR_USERNAME_INVALID": case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = _t('User names may only contain letters, numbers, dots, hyphens and underscores.'); errMsg = _t('User names may only contain letters, numbers, dots, hyphens and underscores.');
break; break;
@ -355,7 +401,7 @@ module.exports = React.createClass({
poll={true} poll={true}
/> />
); );
} else if (this.state.busy || this.state.teamServerBusy) { } else if (this.state.busy || this.state.teamServerBusy || !this.state.flows) {
registerBody = <Spinner />; registerBody = <Spinner />;
} else { } else {
let serverConfigSection; let serverConfigSection;
@ -385,6 +431,7 @@ module.exports = React.createClass({
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected} onTeamSelected={this.onTeamSelected}
flows={this.state.flows}
/> />
{ serverConfigSection } { serverConfigSection }
</div> </div>

View file

@ -87,7 +87,7 @@ module.exports = React.createClass({
if (this.unmounted) return; if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing. // Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING or PREPARED. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState; const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected && if (reconnected &&
// Did we fall back? // Did we fall back?

View file

@ -19,6 +19,7 @@ import {ContentRepo} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg"; import MatrixClientPeg from "../../../MatrixClientPeg";
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import sdk from "../../../index"; import sdk from "../../../index";
import DMRoomMap from '../../../utils/DMRoomMap';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomAvatar', displayName: 'RoomAvatar',
@ -107,58 +108,29 @@ module.exports = React.createClass({
}, },
getOneToOneAvatar: function(props) { getOneToOneAvatar: function(props) {
if (!props.room) return null; const room = props.room;
if (!room) {
const mlist = props.room.currentState.members; return null;
const userIds = [];
const leftUserIds = [];
// for .. in optimisation to return early if there are >2 keys
for (const uid in mlist) {
if (mlist.hasOwnProperty(uid)) {
if (["join", "invite"].includes(mlist[uid].membership)) {
userIds.push(uid);
} else {
leftUserIds.push(uid);
}
}
if (userIds.length > 2) {
return null;
}
} }
let otherMember = null;
if (userIds.length == 2) { const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
let theOtherGuy = null; if (otherUserId) {
if (mlist[userIds[0]].userId == MatrixClientPeg.get().credentials.userId) { otherMember = room.getMember(otherUserId);
theOtherGuy = mlist[userIds[1]];
} else {
theOtherGuy = mlist[userIds[0]];
}
return theOtherGuy.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
} else if (userIds.length == 1) {
// The other 1-1 user left, leaving just the current user, so show the left user's avatar
if (leftUserIds.length === 1) {
return mlist[leftUserIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
false,
);
}
return mlist[userIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
} else { } else {
return null; // if the room is not marked as a 1:1, but only has max 2 members
// then still try to show any avatar (pref. other member)
otherMember = room.getAvatarFallbackMember();
} }
if (otherMember) {
return otherMember.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
false,
);
}
return null;
}, },
onRoomAvatarClick: function() { onRoomAvatarClick: function() {

View file

@ -15,10 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
@ -179,7 +178,7 @@ module.exports = React.createClass({
onQuoteClick: function() { onQuoteClick: function() {
dis.dispatch({ dis.dispatch({
action: 'quote', action: 'quote',
text: this.props.eventTileOps.getInnerText(), event: this.props.mxEvent,
}); });
this.closeMenu(); this.closeMenu();
}, },
@ -220,7 +219,10 @@ module.exports = React.createClass({
let replyButton; let replyButton;
let collapseReplyThread; let collapseReplyThread;
if (eventStatus === 'not_sent') { // status is SENT before remote-echo, null after
const isSent = !eventStatus || eventStatus === EventStatus.SENT;
if (eventStatus === EventStatus.NOT_SENT) {
resendButton = ( resendButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onResendClick}> <div className="mx_MessageContextMenu_field" onClick={this.onResendClick}>
{ _t('Resend') } { _t('Resend') }
@ -228,7 +230,7 @@ module.exports = React.createClass({
); );
} }
if (!eventStatus && this.state.canRedact) { if (isSent && this.state.canRedact) {
redactButton = ( redactButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}> <div className="mx_MessageContextMenu_field" onClick={this.onRedactClick}>
{ _t('Remove') } { _t('Remove') }
@ -236,7 +238,7 @@ module.exports = React.createClass({
); );
} }
if (eventStatus === "queued" || eventStatus === "not_sent") { if (eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT) {
cancelButton = ( cancelButton = (
<div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}> <div className="mx_MessageContextMenu_field" onClick={this.onCancelSendClick}>
{ _t('Cancel Sending') } { _t('Cancel Sending') }
@ -244,7 +246,7 @@ module.exports = React.createClass({
); );
} }
if (!eventStatus && this.props.mxEvent.getType() === 'm.room.message') { if (isSent && this.props.mxEvent.getType() === 'm.room.message') {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) { if (content.msgtype && content.msgtype !== 'm.bad.encrypted' && content.hasOwnProperty('body')) {
forwardButton = ( forwardButton = (

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