Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into travis/single_unsent

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

# Conflicts:
#	src/components/structures/RoomStatusBar.js
#	src/components/structures/RoomView.js
#	src/i18n/strings/en_EN.json
This commit is contained in:
Michael Telatynski 2018-01-04 15:03:11 +00:00
commit f63e6fdedf
No known key found for this signature in database
GPG key ID: 0435A1D4BBD34D64
213 changed files with 20492 additions and 5682 deletions

View file

@ -29,10 +29,16 @@ module.exports = {
// so we replace it with a version that is class property aware // so we replace it with a version that is class property aware
"babel/no-invalid-this": "error", "babel/no-invalid-this": "error",
// We appear to follow this most of the time, so let's enforce it instead
// of occasionally following it (or catching it in review)
"keyword-spacing": "error",
/** react **/ /** react **/
// This just uses the react plugin to help eslint known when // This just uses the react plugin to help eslint known when
// variables have been used in JSX // variables have been used in JSX
"react/jsx-uses-vars": "error", "react/jsx-uses-vars": "error",
// Don't mark React as unused if we're using JSX
"react/jsx-uses-react": "error",
// bind or arrow function in props causes performance issues // bind or arrow function in props causes performance issues
"react/jsx-no-bind": ["error", { "react/jsx-no-bind": ["error", {

View file

@ -1,3 +1,444 @@
Changes in [0.11.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.3) (2017-12-04)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3)
* Bump js-sdk version to pull in fix for [setting room publicity in a group](https://github.com/matrix-org/matrix-js-sdk/commit/aa3201ebb0fff5af2fb733080aa65ed1f7213de6).
Changes in [0.11.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.2) (2017-11-28)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.1...v0.11.2)
* Ignore unrecognised login flows
[\#1633](https://github.com/matrix-org/matrix-react-sdk/pull/1633)
Changes in [0.11.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.1) (2017-11-17)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0...v0.11.1)
* Fix the force TURN option
[\#1621](https://github.com/matrix-org/matrix-react-sdk/pull/1621)
Changes in [0.11.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0) (2017-11-15)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.3...v0.11.0)
Changes in [0.11.0-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.3) (2017-11-14)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.2...v0.11.0-rc.3)
Changes in [0.11.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.2) (2017-11-10)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.1...v0.11.0-rc.2)
* Make groups a fully-fleged baked-in feature
[\#1603](https://github.com/matrix-org/matrix-react-sdk/pull/1603)
Changes in [0.11.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.1) (2017-11-10)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7...v0.11.0-rc.1)
* Improve widget rendering on prop updates
[\#1548](https://github.com/matrix-org/matrix-react-sdk/pull/1548)
* Display group member profile (avatar/displayname) in ConfirmUserActionDialog
[\#1595](https://github.com/matrix-org/matrix-react-sdk/pull/1595)
* Don't crash if there isn't a room notif rule
[\#1602](https://github.com/matrix-org/matrix-react-sdk/pull/1602)
* Show group name in flair tooltip if one is set
[\#1596](https://github.com/matrix-org/matrix-react-sdk/pull/1596)
* Convert group avatar URL to HTTP before handing to BaseAvatar
[\#1597](https://github.com/matrix-org/matrix-react-sdk/pull/1597)
* Add group features as starting points for ILAG
[\#1601](https://github.com/matrix-org/matrix-react-sdk/pull/1601)
* Modify the group room visibility API to reflect the js-sdk changes
[\#1598](https://github.com/matrix-org/matrix-react-sdk/pull/1598)
* Update from Weblate.
[\#1599](https://github.com/matrix-org/matrix-react-sdk/pull/1599)
* Revert "UnknownDeviceDialog: get devices from SDK"
[\#1594](https://github.com/matrix-org/matrix-react-sdk/pull/1594)
* Order users in the group member list with admins first
[\#1591](https://github.com/matrix-org/matrix-react-sdk/pull/1591)
* Fetch group members after accepting an invite
[\#1592](https://github.com/matrix-org/matrix-react-sdk/pull/1592)
* Improve address picker for rooms
[\#1589](https://github.com/matrix-org/matrix-react-sdk/pull/1589)
* Fix FlairStore getPublicisedGroupsCached to give the correct, existing
promise
[\#1590](https://github.com/matrix-org/matrix-react-sdk/pull/1590)
* Use the getProfileInfo API for group inviter profile
[\#1585](https://github.com/matrix-org/matrix-react-sdk/pull/1585)
* Add checkbox to GroupAddressPicker for determining visibility of group rooms
[\#1587](https://github.com/matrix-org/matrix-react-sdk/pull/1587)
* Alter group member api
[\#1581](https://github.com/matrix-org/matrix-react-sdk/pull/1581)
* Improve group creation UX
[\#1580](https://github.com/matrix-org/matrix-react-sdk/pull/1580)
* Disable RoomDetailList in GroupView when editing
[\#1583](https://github.com/matrix-org/matrix-react-sdk/pull/1583)
* Default to no read pins if there is no applicable account data
[\#1586](https://github.com/matrix-org/matrix-react-sdk/pull/1586)
* UnknownDeviceDialog: get devices from SDK
[\#1584](https://github.com/matrix-org/matrix-react-sdk/pull/1584)
* Add a small indicator for when a new event is pinned
[\#1486](https://github.com/matrix-org/matrix-react-sdk/pull/1486)
* Implement tooltip for group rooms
[\#1582](https://github.com/matrix-org/matrix-react-sdk/pull/1582)
* Room notifs in autocomplete & composer
[\#1577](https://github.com/matrix-org/matrix-react-sdk/pull/1577)
* Ignore img tags in HTML if src is not specified
[\#1579](https://github.com/matrix-org/matrix-react-sdk/pull/1579)
* Indicate admins in the group member list with a sheriff badge
[\#1578](https://github.com/matrix-org/matrix-react-sdk/pull/1578)
* Remember whether widget drawer was hidden per-room
[\#1533](https://github.com/matrix-org/matrix-react-sdk/pull/1533)
* Throw an error when trying to create a group store with falsey groupId
[\#1576](https://github.com/matrix-org/matrix-react-sdk/pull/1576)
* Fixes React warning
[\#1571](https://github.com/matrix-org/matrix-react-sdk/pull/1571)
* Fix Flair not appearing due to missing this._usersInFlight
[\#1575](https://github.com/matrix-org/matrix-react-sdk/pull/1575)
* Use, if possible, a room's canonical or first alias when viewing the …
[\#1574](https://github.com/matrix-org/matrix-react-sdk/pull/1574)
* Add CSS classes to group ID input in CreateGroupDialog
[\#1573](https://github.com/matrix-org/matrix-react-sdk/pull/1573)
* Give autocomplete providers the room they're in
[\#1568](https://github.com/matrix-org/matrix-react-sdk/pull/1568)
* Fix multiple pills on one line
[\#1572](https://github.com/matrix-org/matrix-react-sdk/pull/1572)
* Fix group invites such that they look similar to room invites
[\#1570](https://github.com/matrix-org/matrix-react-sdk/pull/1570)
* Add a GeminiScrollbar to Your Communities
[\#1569](https://github.com/matrix-org/matrix-react-sdk/pull/1569)
* Fix multiple requests for publicised groups of given user
[\#1567](https://github.com/matrix-org/matrix-react-sdk/pull/1567)
* Add toggle to alter visibility of a room-group association
[\#1566](https://github.com/matrix-org/matrix-react-sdk/pull/1566)
* Pillify room notifs in the timeline
[\#1564](https://github.com/matrix-org/matrix-react-sdk/pull/1564)
* Implement simple GroupRoomInfo
[\#1563](https://github.com/matrix-org/matrix-react-sdk/pull/1563)
* turn NPE on flair resolution errors into a logged error
[\#1565](https://github.com/matrix-org/matrix-react-sdk/pull/1565)
* Less translation in parts
[\#1484](https://github.com/matrix-org/matrix-react-sdk/pull/1484)
* Redact group IDs from analytics
[\#1562](https://github.com/matrix-org/matrix-react-sdk/pull/1562)
* Display whether the group summary/room list is loading
[\#1560](https://github.com/matrix-org/matrix-react-sdk/pull/1560)
* Change client-side validation of group IDs to match synapse
[\#1558](https://github.com/matrix-org/matrix-react-sdk/pull/1558)
* Prevent non-members from opening group settings
[\#1559](https://github.com/matrix-org/matrix-react-sdk/pull/1559)
* Alter UI for disinviting a group member
[\#1556](https://github.com/matrix-org/matrix-react-sdk/pull/1556)
* Only show admin tools to privileged users
[\#1555](https://github.com/matrix-org/matrix-react-sdk/pull/1555)
* Try lowercase username on login
[\#1550](https://github.com/matrix-org/matrix-react-sdk/pull/1550)
* Don't refresh page on password change prompt
[\#1554](https://github.com/matrix-org/matrix-react-sdk/pull/1554)
* Fix initial in GroupAvatar in GroupView
[\#1553](https://github.com/matrix-org/matrix-react-sdk/pull/1553)
* Use "crop" method to scale group avatars in MyGroups
[\#1549](https://github.com/matrix-org/matrix-react-sdk/pull/1549)
* Lowercase all usernames
[\#1547](https://github.com/matrix-org/matrix-react-sdk/pull/1547)
* Add sensible missing entry generator for MELS tests
[\#1546](https://github.com/matrix-org/matrix-react-sdk/pull/1546)
* Fix prompt to re-use chat room
[\#1545](https://github.com/matrix-org/matrix-react-sdk/pull/1545)
* Add unregiseterListener to GroupStore
[\#1544](https://github.com/matrix-org/matrix-react-sdk/pull/1544)
* Fix groups invited users err for non members
[\#1543](https://github.com/matrix-org/matrix-react-sdk/pull/1543)
* Add Mention button to MemberInfo
[\#1532](https://github.com/matrix-org/matrix-react-sdk/pull/1532)
* Only show group settings cog to members
[\#1541](https://github.com/matrix-org/matrix-react-sdk/pull/1541)
* Use correct icon for group room deletion and make themeable
[\#1540](https://github.com/matrix-org/matrix-react-sdk/pull/1540)
* Add invite button to MemberInfo if user has left or wasn't in room
[\#1534](https://github.com/matrix-org/matrix-react-sdk/pull/1534)
* Add option to mirror local video feed
[\#1539](https://github.com/matrix-org/matrix-react-sdk/pull/1539)
* Use the correct userId when displaying who redacted a message
[\#1538](https://github.com/matrix-org/matrix-react-sdk/pull/1538)
* Only show editing UI for aliases/related_groups for users /w power
[\#1529](https://github.com/matrix-org/matrix-react-sdk/pull/1529)
* Swap from `ui_opacity` to `panel_disabled`
[\#1535](https://github.com/matrix-org/matrix-react-sdk/pull/1535)
* Fix room address picker tiles default name
[\#1536](https://github.com/matrix-org/matrix-react-sdk/pull/1536)
* T3chguy/hide level change on 50
[\#1531](https://github.com/matrix-org/matrix-react-sdk/pull/1531)
* fix missing date sep caused by hidden event at start of day
[\#1537](https://github.com/matrix-org/matrix-react-sdk/pull/1537)
* Add a delete confirmation dialog for widgets
[\#1520](https://github.com/matrix-org/matrix-react-sdk/pull/1520)
* When dispatching view_[my_]group[s], reset RoomViewStore
[\#1530](https://github.com/matrix-org/matrix-react-sdk/pull/1530)
* Prevent editing of UI requiring user privilege if user unprivileged
[\#1528](https://github.com/matrix-org/matrix-react-sdk/pull/1528)
* Use the correct property of the API room objects
[\#1526](https://github.com/matrix-org/matrix-react-sdk/pull/1526)
* Don't include the |other in the translation value
[\#1527](https://github.com/matrix-org/matrix-react-sdk/pull/1527)
* Re-run gen-i18n after fixing https://github.com/matrix-org/matrix-react-
sdk/pull/1521
[\#1525](https://github.com/matrix-org/matrix-react-sdk/pull/1525)
* Fix some react warnings in GroupMemberList
[\#1522](https://github.com/matrix-org/matrix-react-sdk/pull/1522)
* Fix bug with gen-i18n/js when adding new plurals
[\#1521](https://github.com/matrix-org/matrix-react-sdk/pull/1521)
* Make GroupStoreCache global for cross-package access
[\#1524](https://github.com/matrix-org/matrix-react-sdk/pull/1524)
* Add fields needed by RoomDetailList to groupRoomFromApiObject
[\#1523](https://github.com/matrix-org/matrix-react-sdk/pull/1523)
* Only show flair for groups with avatars set
[\#1519](https://github.com/matrix-org/matrix-react-sdk/pull/1519)
* Refresh group member lists after inviting users
[\#1518](https://github.com/matrix-org/matrix-react-sdk/pull/1518)
* Invalidate the user's public groups cache when changing group publicity
[\#1517](https://github.com/matrix-org/matrix-react-sdk/pull/1517)
* Make the gen-i18n script validate _t calls
[\#1515](https://github.com/matrix-org/matrix-react-sdk/pull/1515)
* Add placeholder to MyGroups page, adjust CSS classes
[\#1514](https://github.com/matrix-org/matrix-react-sdk/pull/1514)
* Rxl881/parallelshell
[\#1338](https://github.com/matrix-org/matrix-react-sdk/pull/1338)
* Run prunei18n
[\#1513](https://github.com/matrix-org/matrix-react-sdk/pull/1513)
* Update from Weblate.
[\#1512](https://github.com/matrix-org/matrix-react-sdk/pull/1512)
* Add script to prune unused translations
[\#1502](https://github.com/matrix-org/matrix-react-sdk/pull/1502)
* Fix creation of DM rooms
[\#1510](https://github.com/matrix-org/matrix-react-sdk/pull/1510)
* Group create dialog: only enter localpart
[\#1507](https://github.com/matrix-org/matrix-react-sdk/pull/1507)
* Improve MyGroups UI
[\#1509](https://github.com/matrix-org/matrix-react-sdk/pull/1509)
* Use object URLs to load Files in to images
[\#1508](https://github.com/matrix-org/matrix-react-sdk/pull/1508)
* Add clientside error for non-alphanumeric group ID
[\#1506](https://github.com/matrix-org/matrix-react-sdk/pull/1506)
* Fix invites to groups without names
[\#1505](https://github.com/matrix-org/matrix-react-sdk/pull/1505)
* Add warning when adding group rooms/users
[\#1504](https://github.com/matrix-org/matrix-react-sdk/pull/1504)
* More Groups->Communities
[\#1503](https://github.com/matrix-org/matrix-react-sdk/pull/1503)
* Groups -> Communities
[\#1501](https://github.com/matrix-org/matrix-react-sdk/pull/1501)
* Factor out Flair cache into FlairStore
[\#1500](https://github.com/matrix-org/matrix-react-sdk/pull/1500)
* Add i18n script to package.json
[\#1499](https://github.com/matrix-org/matrix-react-sdk/pull/1499)
* Make gen-i18n support 'HTML'
[\#1498](https://github.com/matrix-org/matrix-react-sdk/pull/1498)
* fix editing visuals on groupview header
[\#1497](https://github.com/matrix-org/matrix-react-sdk/pull/1497)
* Script to generate the translations base file
[\#1493](https://github.com/matrix-org/matrix-react-sdk/pull/1493)
* Update from Weblate.
[\#1495](https://github.com/matrix-org/matrix-react-sdk/pull/1495)
* Attempt to relate a group to a room when adding it
[\#1494](https://github.com/matrix-org/matrix-react-sdk/pull/1494)
* Shuffle GroupView UI
[\#1490](https://github.com/matrix-org/matrix-react-sdk/pull/1490)
* Fix bug preventing partial group profile
[\#1491](https://github.com/matrix-org/matrix-react-sdk/pull/1491)
* Don't show room IDs when picking rooms
[\#1492](https://github.com/matrix-org/matrix-react-sdk/pull/1492)
* Only show invited section if there are invited group members
[\#1489](https://github.com/matrix-org/matrix-react-sdk/pull/1489)
* Show "Invited" section in the user list
[\#1488](https://github.com/matrix-org/matrix-react-sdk/pull/1488)
* Refactor class names for an entity tile being hovered over
[\#1487](https://github.com/matrix-org/matrix-react-sdk/pull/1487)
* Modify GroupView UI
[\#1475](https://github.com/matrix-org/matrix-react-sdk/pull/1475)
* Message/event pinning
[\#1439](https://github.com/matrix-org/matrix-react-sdk/pull/1439)
* Remove duplicate declaration that breaks the build
[\#1483](https://github.com/matrix-org/matrix-react-sdk/pull/1483)
* Include magnet scheme in sanitize HTML params
[\#1301](https://github.com/matrix-org/matrix-react-sdk/pull/1301)
* Add a way to jump to a user's Read Receipt from MemberInfo
[\#1454](https://github.com/matrix-org/matrix-react-sdk/pull/1454)
* Use standard subsitution syntax in _tJsx
[\#1462](https://github.com/matrix-org/matrix-react-sdk/pull/1462)
* Don't suggest grey as a color scheme for a room
[\#1442](https://github.com/matrix-org/matrix-react-sdk/pull/1442)
* allow hiding of notification body for privacy reasons
[\#1362](https://github.com/matrix-org/matrix-react-sdk/pull/1362)
* Suggest to invite people when speaking in an empty room
[\#1466](https://github.com/matrix-org/matrix-react-sdk/pull/1466)
* Buttons to remove room/self avatar
[\#1478](https://github.com/matrix-org/matrix-react-sdk/pull/1478)
* T3chguy/fix memberlist
[\#1480](https://github.com/matrix-org/matrix-react-sdk/pull/1480)
* add option to disable BigEmoji
[\#1481](https://github.com/matrix-org/matrix-react-sdk/pull/1481)
Changes in [0.10.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7) (2017-10-16)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7-rc.3...v0.10.7)
* Update to latest js-sdk
Changes in [0.10.7-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7-rc.3) (2017-10-13)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7-rc.2...v0.10.7-rc.3)
* Fix the enableLabs flag, again
[\#1474](https://github.com/matrix-org/matrix-react-sdk/pull/1474)
Changes in [0.10.7-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7-rc.2) (2017-10-13)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7-rc.1...v0.10.7-rc.2)
* Honour the (now legacy) enableLabs flag
[\#1473](https://github.com/matrix-org/matrix-react-sdk/pull/1473)
* Don't show labs features by default
[\#1472](https://github.com/matrix-org/matrix-react-sdk/pull/1472)
* Make features disabled by default
[\#1470](https://github.com/matrix-org/matrix-react-sdk/pull/1470)
Changes in [0.10.7-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7-rc.1) (2017-10-13)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.6...v0.10.7-rc.1)
* Add warm fuzzy dialog for inviting users to a group
[\#1459](https://github.com/matrix-org/matrix-react-sdk/pull/1459)
* enable/disable features in config.json
[\#1468](https://github.com/matrix-org/matrix-react-sdk/pull/1468)
* Update from Weblate.
[\#1469](https://github.com/matrix-org/matrix-react-sdk/pull/1469)
* Don't send RR or RM when peeking at a room
[\#1463](https://github.com/matrix-org/matrix-react-sdk/pull/1463)
* Fix bug that inserted emoji when typing
[\#1467](https://github.com/matrix-org/matrix-react-sdk/pull/1467)
* Ignore VS16 char in RTE
[\#1458](https://github.com/matrix-org/matrix-react-sdk/pull/1458)
* Show failures when sending messages
[\#1460](https://github.com/matrix-org/matrix-react-sdk/pull/1460)
* Run eslint --fix
[\#1461](https://github.com/matrix-org/matrix-react-sdk/pull/1461)
* Show who banned the user on hover
[\#1441](https://github.com/matrix-org/matrix-react-sdk/pull/1441)
* Enhancements to room power level settings
[\#1440](https://github.com/matrix-org/matrix-react-sdk/pull/1440)
* Added TextInputWithCheckbox dialog
[\#868](https://github.com/matrix-org/matrix-react-sdk/pull/868)
* Make it clearer which HS you're logging into
[\#1456](https://github.com/matrix-org/matrix-react-sdk/pull/1456)
* Remove redundant stale onKeyDown
[\#1451](https://github.com/matrix-org/matrix-react-sdk/pull/1451)
* Only allow event state event handlers on state events
[\#1453](https://github.com/matrix-org/matrix-react-sdk/pull/1453)
* Modify the group store to include group rooms
[\#1452](https://github.com/matrix-org/matrix-react-sdk/pull/1452)
* Factor-out GroupStore and create GroupStoreCache
[\#1449](https://github.com/matrix-org/matrix-react-sdk/pull/1449)
* Put related groups UI behind groups labs flag
[\#1448](https://github.com/matrix-org/matrix-react-sdk/pull/1448)
* Restrict Flair in the timeline to related groups of the room
[\#1447](https://github.com/matrix-org/matrix-react-sdk/pull/1447)
* Implement UI for editing related groups of a room
[\#1446](https://github.com/matrix-org/matrix-react-sdk/pull/1446)
* Fix a couple of bugs with EditableItemList
[\#1445](https://github.com/matrix-org/matrix-react-sdk/pull/1445)
* Factor out EditableItemList from AliasSettings
[\#1444](https://github.com/matrix-org/matrix-react-sdk/pull/1444)
* Add dummy translation function to mark translatable strings
[\#1421](https://github.com/matrix-org/matrix-react-sdk/pull/1421)
* Implement button to remove a room from a group
[\#1438](https://github.com/matrix-org/matrix-react-sdk/pull/1438)
* Fix showing 3pid invites in member list
[\#1443](https://github.com/matrix-org/matrix-react-sdk/pull/1443)
* Add button to get to MyGroups (view_my_groups or path #/groups)
[\#1435](https://github.com/matrix-org/matrix-react-sdk/pull/1435)
* Add eslint rule to disallow spaces inside of curly braces
[\#1436](https://github.com/matrix-org/matrix-react-sdk/pull/1436)
* Fix ability to invite existing mx users
[\#1437](https://github.com/matrix-org/matrix-react-sdk/pull/1437)
* Construct address picker message using provided `validAddressTypes`
[\#1434](https://github.com/matrix-org/matrix-react-sdk/pull/1434)
* Fix GroupView summary rooms displaying without avatars
[\#1433](https://github.com/matrix-org/matrix-react-sdk/pull/1433)
* Implement adding rooms to a group (or group summary) by room ID
[\#1432](https://github.com/matrix-org/matrix-react-sdk/pull/1432)
* Give flair avatars a tooltip = the group ID
[\#1431](https://github.com/matrix-org/matrix-react-sdk/pull/1431)
* Fix ability to feature self in a group summary
[\#1430](https://github.com/matrix-org/matrix-react-sdk/pull/1430)
* Implement "Add room to group" feature
[\#1429](https://github.com/matrix-org/matrix-react-sdk/pull/1429)
* Fix group membership publicity
[\#1428](https://github.com/matrix-org/matrix-react-sdk/pull/1428)
* Add support for Jitsi screensharing in electron app
[\#1355](https://github.com/matrix-org/matrix-react-sdk/pull/1355)
* Delint and DRY TextForEvent
[\#1424](https://github.com/matrix-org/matrix-react-sdk/pull/1424)
* Bust the flair caches after 30mins
[\#1427](https://github.com/matrix-org/matrix-react-sdk/pull/1427)
* Show displayname / avatar in group member info
[\#1426](https://github.com/matrix-org/matrix-react-sdk/pull/1426)
* Create GroupSummaryStore for storing group summary stuff
[\#1418](https://github.com/matrix-org/matrix-react-sdk/pull/1418)
* Add status & toggle for publicity
[\#1419](https://github.com/matrix-org/matrix-react-sdk/pull/1419)
* MemberList: show 100 more on overflow tile click
[\#1417](https://github.com/matrix-org/matrix-react-sdk/pull/1417)
* Fix NPE in MemberList
[\#1425](https://github.com/matrix-org/matrix-react-sdk/pull/1425)
* Fix incorrect variable in string
[\#1422](https://github.com/matrix-org/matrix-react-sdk/pull/1422)
* apply i18n _t to string which has already been translated
[\#1420](https://github.com/matrix-org/matrix-react-sdk/pull/1420)
* Make the invite section a truncatedlist too
[\#1416](https://github.com/matrix-org/matrix-react-sdk/pull/1416)
* Implement removal function of features users/rooms
[\#1415](https://github.com/matrix-org/matrix-react-sdk/pull/1415)
* Allow TruncatedList to get children via a callback
[\#1412](https://github.com/matrix-org/matrix-react-sdk/pull/1412)
* Experimental: Lazy load user autocomplete entries
[\#1413](https://github.com/matrix-org/matrix-react-sdk/pull/1413)
* Show displayname & avatar url in group member list
[\#1414](https://github.com/matrix-org/matrix-react-sdk/pull/1414)
* De-lint TruncatedList
[\#1411](https://github.com/matrix-org/matrix-react-sdk/pull/1411)
* Remove unneeded strings
[\#1409](https://github.com/matrix-org/matrix-react-sdk/pull/1409)
* Clean on prerelease
[\#1410](https://github.com/matrix-org/matrix-react-sdk/pull/1410)
* Redesign membership section in GroupView
[\#1408](https://github.com/matrix-org/matrix-react-sdk/pull/1408)
* Implement adding rooms to the group summary
[\#1406](https://github.com/matrix-org/matrix-react-sdk/pull/1406)
* Honour the is_privileged flag in GroupView
[\#1407](https://github.com/matrix-org/matrix-react-sdk/pull/1407)
* Update when a group arrives
[\#1405](https://github.com/matrix-org/matrix-react-sdk/pull/1405)
* Implement `view_group` dispatch when clicking flair
[\#1404](https://github.com/matrix-org/matrix-react-sdk/pull/1404)
* GroupView: Add a User
[\#1402](https://github.com/matrix-org/matrix-react-sdk/pull/1402)
* Track action button click event
[\#1403](https://github.com/matrix-org/matrix-react-sdk/pull/1403)
* Separate sender profile into elements with classes
[\#1401](https://github.com/matrix-org/matrix-react-sdk/pull/1401)
* Fix ugly integration button, use hover to show error
[\#1399](https://github.com/matrix-org/matrix-react-sdk/pull/1399)
* Fix promise error in flair
[\#1400](https://github.com/matrix-org/matrix-react-sdk/pull/1400)
* Flair!
[\#1351](https://github.com/matrix-org/matrix-react-sdk/pull/1351)
* Group Membership UI
[\#1328](https://github.com/matrix-org/matrix-react-sdk/pull/1328)
Changes in [0.10.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.6) (2017-09-21) Changes in [0.10.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.6) (2017-09-21)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.5...v0.10.6) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.5...v0.10.6)

151
docs/settings.md Normal file
View file

@ -0,0 +1,151 @@
# Settings Reference
This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify different values for a setting at particular levels of interest. For example, a user may say that in a particular room they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity of dealing with the different levels and exposes easy to use getters and setters.
## Levels
Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in order of prioirty, are:
* `device` - The current user's device
* `room-device` - The current user's device, but only when in a specific room
* `room-account` - The current user's account, but only when in a specific room
* `account` - The current user's account
* `room` - A specific room (setting for all members of the room)
* `config` - Values are defined by `config.json`
* `default` - The hardcoded default for the settings
Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure that room administrators cannot force account-only settings upon participants.
## Settings
Settings are the different options a user may set or experience in the application. These are pre-defined in `src/settings/Settings.js` under the `SETTINGS` constant and have the following minimum requirements:
```
// The ID is used to reference the setting throughout the application. This must be unique.
"theSettingId": {
// The levels this setting supports is required. In `src/settings/Settings.js` there are various pre-set arrays
// for this option - they should be used where possible to avoid copy/pasting arrays across settings.
supportedLevels: [...],
// The default for this setting serves two purposes: It provides a value if the setting is not defined at other
// levels, and it serves to demonstrate the expected type to other developers. The value isn't enforced, but it
// should be respected throughout the code. The default may be any data type.
default: false,
// The display name has two notations: string and object. The object notation allows for different translatable
// strings to be used for different levels, while the string notation represents the string for all levels.
displayName: _td("Change something"), // effectively `displayName: { "default": _td("Change something") }`
displayName: {
"room": _td("Change something for participants of this room"),
// Note: the default will be used if the level requested (such as `device`) does not have a string defined here.
"default": _td("Change something"),
}
}
```
### Getting values for a setting
After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value returned is best represented in the room, particularly if the setting ever gets a per-room level in the future.
In settings pages it is often desired to have the value at a particular level instead of getting the calculated value. Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the target level.
### Setting values for a setting
Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue although there are circumstances where this changes. An example of a safe call is:
```javascript
const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM);
if (isSupported) {
const canSetValue = SettingsStore.canSetValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM);
if (canSetValue) {
SettingsStore.setValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM, newValue);
}
}
```
These checks may also be performed in different areas of the application to avoid the verbose example above. For instance, the component which allows changing the setting may be hidden conditionally on the above conditions.
##### `SettingsFlag` component
Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The `SettingsFlag` also supports simple radio button options, such as the theme the user would like to use.
```html
<SettingsFlag name="theSettingId"
level={SettingsLevel.ROOM}
roomId="!curbf:matrix.org"
label={_td("Your label here")} // optional, if falsey then the `SettingsStore` will be used
onChange={function(newValue) { }} // optional, called after saving
isExplicit={false} // this is passed along to `SettingsStore.getValueAt`, defaulting to false
manualSave={false} // if true, saving is delayed. You will need to call .save() on this component
// Options for radio buttons
group="your-radio-group" // this enables radio button support
value="yourValueHere" // the value for this particular option
/>
```
### Getting the display name for a setting
Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated for you. If a display name cannot be found, it will return `null`.
## Features
Occasionally some parts of the application may be undergoing testing and are not quite production ready. These are commonly known to be behind a "labs flag". Features behind lab flags must go through the granular settings system, and look and act very much normal settings. The exception is that they must supply `isFeature: true` as part of the setting definition and should go through the helper functions on `SettingsStore`.
### Determining if a feature is enabled
A simple call to `SettingsStore.isFeatureEnabled` will tell you if the feature is enabled. This will perform all the required calculations to determine if the feature is enabled based upon the configuration and user selection.
### Enabling a feature
Features can only be enabled if the feature is in the `labs` state, otherwise this is a no-op. To find the current set of features in the `labs` state, call `SettingsStore.getLabsFeatures`. To set the value, call `SettingsStore.setFeatureEnabled`.
## Setting controllers
Settings may have environmental factors that affect their value or need additional code to be called when they are modified. A setting controller is able to override the calculated value for a setting and react to changes in that setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings: they can only be considered enabled if the platform supports notifications, and enabling notifications requires additional steps to actually enable notifications.
For more information, see `src/settings/controllers/SettingController.js`.
## Local echo
`SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that the request will go through successfully. The local echo only takes effect until the request to save a setting has completed (either successfully or otherwise).
```javascript
SettingsStore.setValue(...).then(() => {
// The value has actually been stored at this point.
});
SettingsStore.getValue(...); // this will return the value set in `setValue` above.
```
# Maintainers Reference
The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is supposed to work.
### General information
The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure. The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each level should be defined in this array, including `default`.
Handlers (`src/settings/handlers/SettingsHandler.js`) represent a single level and are responsible for getting and setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for their level (for example, a setting being renamed or using a different key from other settings in the underlying store). Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by only considering handlers that are supported on the platform.
Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.js` which acts as a wrapper around a given handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated immediately upon the proxied save call succeeding or failing.
Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the `SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code.
### Features
Features automatically get considered as `disabled` if they are not listed in the `SdkConfig` or `enable_labs` is false/not set. Features are always checked against the configuration before going through the level order as they have the option of being forced-on or forced-off for the application. This is done by the `features` section and looks something like this:
```
"features": {
"feature_groups": "enable",
"feature_pinning": "disable", // the default
"feature_presence": "labs"
}
```
If `enableLabs` is true in the configuration, the default for features becomes `"labs"`.

6551
package-lock.json generated Normal file

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.10.6", "version": "0.11.3",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -28,11 +28,15 @@
"test" "test"
], ],
"bin": { "bin": {
"reskindex": "scripts/reskindex.js" "reskindex": "scripts/reskindex.js",
"matrix-gen-i18n": "scripts/gen-i18n.js",
"matrix-prune-i18n": "scripts/prune-i18n.js"
}, },
"scripts": { "scripts": {
"reskindex": "node scripts/reskindex.js -h header", "reskindex": "node scripts/reskindex.js -h header",
"reskindex:watch": "node scripts/reskindex.js -h header -w", "reskindex:watch": "node scripts/reskindex.js -h header -w",
"i18n": "matrix-gen-i18n",
"prunei18n": "matrix-prune-i18n",
"build": "npm run reskindex && babel src -d lib --source-maps --copy-files", "build": "npm run reskindex && babel src -d lib --source-maps --copy-files",
"build:watch": "babel src -w -d lib --source-maps --copy-files", "build:watch": "babel src -w -d lib --source-maps --copy-files",
"emoji-data-strip": "node scripts/emoji-data-strip.js", "emoji-data-strip": "node scripts/emoji-data-strip.js",
@ -67,11 +71,14 @@
"isomorphic-fetch": "^2.2.1", "isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3", "linkifyjs": "^2.1.3",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"matrix-js-sdk": "0.8.4", "matrix-js-sdk": "0.9.2",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"querystring": "^0.2.0",
"react": "^15.4.0", "react": "^15.4.0",
"react-addons-css-transition-group": "15.3.2", "react-addons-css-transition-group": "15.3.2",
"react-dnd": "^2.1.4",
"react-dnd-html5-backend": "^2.1.2",
"react-dom": "^15.4.0", "react-dom": "^15.4.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"sanitize-html": "^1.14.1", "sanitize-html": "^1.14.1",
@ -101,7 +108,9 @@
"eslint-plugin-babel": "^4.0.1", "eslint-plugin-babel": "^4.0.1",
"eslint-plugin-flowtype": "^2.30.0", "eslint-plugin-flowtype": "^2.30.0",
"eslint-plugin-react": "^7.4.0", "eslint-plugin-react": "^7.4.0",
"estree-walker": "^0.5.0",
"expect": "^1.16.0", "expect": "^1.16.0",
"flow-parser": "^0.57.3",
"json-loader": "^0.5.3", "json-loader": "^0.5.3",
"karma": "^1.7.0", "karma": "^1.7.0",
"karma-chrome-launcher": "^0.2.3", "karma-chrome-launcher": "^0.2.3",
@ -115,12 +124,13 @@
"karma-webpack": "^1.7.0", "karma-webpack": "^1.7.0",
"matrix-react-test-utils": "^0.1.1", "matrix-react-test-utils": "^0.1.1",
"mocha": "^2.4.5", "mocha": "^2.4.5",
"parallelshell": "^1.2.0", "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",
"sinon": "^1.17.3", "sinon": "^1.17.3",
"source-map-loader": "^0.1.5", "source-map-loader": "^0.1.5",
"walk": "^2.3.9",
"webpack": "^1.12.14" "webpack": "^1.12.14"
} }
} }

268
scripts/gen-i18n.js Executable file
View file

@ -0,0 +1,268 @@
#!/usr/bin/env node
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Regenerates the translations en_EN file by walking the source tree and
* parsing each file with flow-parser. Emits a JSON file with the
* translatable strings mapped to themselves in the order they appeared
* in the files and grouped by the file they appeared in.
*
* Usage: node scripts/gen-i18n.js
*/
const fs = require('fs');
const path = require('path');
const walk = require('walk');
const flowParser = require('flow-parser');
const estreeWalker = require('estree-walker');
const TRANSLATIONS_FUNCS = ['_t', '_td'];
const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json';
const OUTPUT_FILE = 'src/i18n/strings/en_EN.json';
// NB. The sync version of walk is broken for single files so we walk
// all of res rather than just res/home.html.
// https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it,
// or if we get bored waiting for it to be merged, we could switch
// to a project that's actively maintained.
const SEARCH_PATHS = ['src', 'res'];
const FLOW_PARSER_OPTS = {
esproposal_class_instance_fields: true,
esproposal_class_static_fields: true,
esproposal_decorators: true,
esproposal_export_star_as: true,
types: true,
};
function getObjectValue(obj, key) {
for (const prop of obj.properties) {
if (prop.key.type == 'Identifier' && prop.key.name == key) {
return prop.value;
}
}
return null;
}
function getTKey(arg) {
if (arg.type == 'Literal') {
return arg.value;
} else if (arg.type == 'BinaryExpression' && arg.operator == '+') {
return getTKey(arg.left) + getTKey(arg.right);
} else if (arg.type == 'TemplateLiteral') {
return arg.quasis.map((q) => {
return q.value.raw;
}).join('');
}
return null;
}
function getFormatStrings(str) {
// Match anything that starts with %
// We could make a regex that matched the full placeholder, but this
// would just not match invalid placeholders and so wouldn't help us
// detect the invalid ones.
// Also note that for simplicity, this just matches a % character and then
// anything up to the next % character (or a single %, or end of string).
const formatStringRe = /%([^%]+|%|$)/g;
const formatStrings = new Set();
let match;
while ( (match = formatStringRe.exec(str)) !== null ) {
const placeholder = match[1]; // Minus the leading '%'
if (placeholder === '%') continue; // Literal % is %%
const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/);
if (placeholderMatch === null) {
throw new Error("Invalid format specifier: '"+match[0]+"'");
}
if (placeholderMatch.length < 3) {
throw new Error("Malformed format specifier");
}
const placeholderName = placeholderMatch[1];
const placeholderFormat = placeholderMatch[2];
if (placeholderFormat !== 's') {
throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`);
}
formatStrings.add(placeholderName);
}
return formatStrings;
}
function getTranslationsJs(file) {
const tree = flowParser.parse(fs.readFileSync(file, { encoding: 'utf8' }), FLOW_PARSER_OPTS);
const trs = new Set();
estreeWalker.walk(tree, {
enter: function(node, parent) {
if (
node.type == 'CallExpression' &&
TRANSLATIONS_FUNCS.includes(node.callee.name)
) {
const tKey = getTKey(node.arguments[0]);
// This happens whenever we call _t with non-literals (ie. whenever we've
// had to use a _td to compensate) so is expected.
if (tKey === null) return;
// check the format string against the args
// We only check _t: _td has no args
if (node.callee.name === '_t') {
try {
const placeholders = getFormatStrings(tKey);
for (const placeholder of placeholders) {
if (node.arguments.length < 2) {
throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`);
}
const value = getObjectValue(node.arguments[1], placeholder);
if (value === null) {
throw new Error(`No value found for placeholder '${placeholder}'`);
}
}
// Validate tag replacements
if (node.arguments.length > 2) {
const tagMap = node.arguments[2];
for (const prop of tagMap.properties) {
if (prop.key.type === 'Literal') {
const tag = prop.key.value;
// RegExp same as in src/languageHandler.js
const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`);
if (!tKey.match(regexp)) {
throw new Error(`No match for ${regexp} in ${tKey}`);
}
}
}
}
} catch (e) {
console.log();
console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`);
process.exit(1);
}
}
let isPlural = false;
if (node.arguments.length > 1 && node.arguments[1].type == 'ObjectExpression') {
const countVal = getObjectValue(node.arguments[1], 'count');
if (countVal) {
isPlural = true;
}
}
if (isPlural) {
trs.add(tKey + "|other");
const plurals = enPlurals[tKey];
if (plurals) {
for (const pluralType of Object.keys(plurals)) {
trs.add(tKey + "|" + pluralType);
}
}
} else {
trs.add(tKey);
}
}
}
});
return trs;
}
function getTranslationsOther(file) {
const contents = fs.readFileSync(file, { encoding: 'utf8' });
const trs = new Set();
// Taken from riot-web src/components/structures/HomePage.js
const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg;
let matches;
while (matches = translationsRegex.exec(contents)) {
trs.add(matches[1]);
}
return trs;
}
// gather en_EN plural strings from the input translations file:
// the en_EN strings are all in the source with the exception of
// pluralised strings, which we need to pull in from elsewhere.
const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' }));
const enPlurals = {};
for (const key of Object.keys(inputTranslationsRaw)) {
const parts = key.split("|");
if (parts.length > 1) {
const plurals = enPlurals[parts[0]] || {};
plurals[parts[1]] = inputTranslationsRaw[key];
enPlurals[parts[0]] = plurals;
}
}
const translatables = new Set();
const walkOpts = {
listeners: {
file: function(root, fileStats, next) {
const fullPath = path.join(root, fileStats.name);
let ltrs;
if (fileStats.name.endsWith('.js')) {
trs = getTranslationsJs(fullPath);
} else if (fileStats.name.endsWith('.html')) {
trs = getTranslationsOther(fullPath);
} else {
return;
}
console.log(`${fullPath} (${trs.size} strings)`);
for (const tr of trs.values()) {
translatables.add(tr);
}
},
}
};
for (const path of SEARCH_PATHS) {
if (fs.existsSync(path)) {
walk.walkSync(path, walkOpts);
}
}
const trObj = {};
for (const tr of translatables) {
if (tr.includes("|")) {
if (inputTranslationsRaw[tr]) {
trObj[tr] = inputTranslationsRaw[tr];
} else {
trObj[tr] = tr.split("|")[0];
}
} else {
trObj[tr] = tr;
}
}
fs.writeFileSync(
OUTPUT_FILE,
JSON.stringify(trObj, translatables.values(), 4) + "\n"
);
console.log();
console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`);

68
scripts/prune-i18n.js Executable file
View file

@ -0,0 +1,68 @@
#!/usr/bin/env node
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
* Looks through all the translation files and removes any strings
* which don't appear in en_EN.json.
* Use this if you remove a translation, but merge any outstanding changes
* from weblate first or you'll need to resolve the conflict in weblate.
*/
const fs = require('fs');
const path = require('path');
const I18NDIR = 'src/i18n/strings';
const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json')));
const enStrings = new Set();
for (const str of Object.keys(enStringsRaw)) {
const parts = str.split('|');
if (parts.length > 1) {
enStrings.add(parts[0]);
} else {
enStrings.add(str);
}
}
for (const filename of fs.readdirSync(I18NDIR)) {
if (filename === 'en_EN.json') continue;
if (filename === 'basefile.json') continue;
if (!filename.endsWith('.json')) continue;
const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename)));
const oldLen = Object.keys(trs).length;
for (const tr of Object.keys(trs)) {
const parts = tr.split('|');
const trKey = parts.length > 1 ? parts[0] : tr;
if (!enStrings.has(trKey)) {
delete trs[tr];
}
}
const removed = oldLen - Object.keys(trs).length;
if (removed > 0) {
console.log(`${filename}: removed ${removed} translations`);
// XXX: This is totally relying on the impl serialising the JSON object in the
// same order as they were parsed from the file. JSON.stringify() has a specific argument
// that can be used to control the order, but JSON.parse() lacks any kind of equivalent.
// Empirically this does maintain the order on my system, so I'm going to leave it like
// this for now.
fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n");
}
}

View file

@ -19,7 +19,7 @@ import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
function getRedactedUrl() { function getRedactedUrl() {
const redactedHash = window.location.hash.replace(/#\/(room|user)\/(.+)/, "#/$1/<redacted>"); const redactedHash = window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/<redacted>");
// hardcoded url to make piwik happy // hardcoded url to make piwik happy
return 'https://riot.im/app/' + redactedHash; return 'https://riot.im/app/' + redactedHash;
} }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 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.
@ -52,13 +53,13 @@ limitations under the License.
*/ */
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import UserSettingsStore from './UserSettingsStore';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import Modal from './Modal'; import Modal from './Modal';
import sdk from './index'; 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 { showUnknownDeviceDialogForCalls } from './cryptodevices';
global.mxCalls = { global.mxCalls = {
//room_id: MatrixCall //room_id: MatrixCall
@ -98,19 +99,54 @@ function pause(audioId) {
} }
} }
function _reAttemptCall(call) {
if (call.direction === 'outbound') {
dis.dispatch({
action: 'place_call',
room_id: call.roomId,
type: call.type,
});
} else {
call.answer();
}
}
function _setCallListeners(call) { function _setCallListeners(call) {
call.on("error", function(err) { call.on("error", function(err) {
console.error("Call error: %s", err); console.error("Call error: %s", err);
console.error(err.stack); console.error(err.stack);
call.hangup(); if (err.code === 'unknown_devices') {
_setCallState(undefined, call.roomId, "ended"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Call Failed', '', QuestionDialog, {
title: _t('Call Failed'),
description: _t(
"There are unknown devices in this room: "+
"if you proceed without verifying them, it will be "+
"possible for someone to eavesdrop on your call."
),
button: _t('Review Devices'),
onFinished: function(confirmed) {
if (confirmed) {
const room = MatrixClientPeg.get().getRoom(call.roomId);
showUnknownDeviceDialogForCalls(
MatrixClientPeg.get(),
room,
() => {
_reAttemptCall(call);
},
call.direction === 'outbound' ? _t("Call Anyway") : _t("Answer Anyway"),
call.direction === 'outbound' ? _t("Call") : _t("Answer"),
);
}
},
}); });
call.on('send_event_error', function(err) { } else {
if (err.name === "UnknownDeviceError") { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
dis.dispatch({
action: 'unknown_device_error', Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
err: err, title: _t('Call Failed'),
room: MatrixClientPeg.get().getRoom(call.roomId), description: err.message,
}); });
} }
}); });
@ -180,7 +216,6 @@ function _setCallState(call, roomId, status) {
function _onAction(payload) { function _onAction(payload) {
function placeCall(newCall) { function placeCall(newCall) {
_setCallListeners(newCall); _setCallListeners(newCall);
_setCallState(newCall, newCall.roomId, "ringback");
if (payload.type === 'voice') { if (payload.type === 'voice') {
newCall.placeVoiceCall(); newCall.placeVoiceCall();
} else if (payload.type === 'video') { } else if (payload.type === 'video') {
@ -245,9 +280,7 @@ function _onAction(payload) {
return; return;
} else if (members.length === 2) { } else if (members.length === 2) {
console.log("Place %s call in %s", payload.type, payload.room_id); console.log("Place %s call in %s", payload.type, payload.room_id);
const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id, { const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
forceTURN: UserSettingsStore.getLocalSetting('webRtcForceTURN', false),
});
placeCall(call); placeCall(call);
} else { // > 2 } else { // > 2
dis.dispatch({ dis.dispatch({

View file

@ -14,8 +14,8 @@
limitations under the License. limitations under the License.
*/ */
import UserSettingsStore from './UserSettingsStore';
import * as Matrix from 'matrix-js-sdk'; import * as Matrix from 'matrix-js-sdk';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
export default { export default {
getDevices: function() { getDevices: function() {
@ -43,22 +43,20 @@ export default {
}, },
loadDevices: function() { loadDevices: function() {
// this.getDevices().then((devices) => { const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const localSettings = UserSettingsStore.getLocalSettings(); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
// // if deviceId is not found, automatic fallback is in spec
// // recall previously stored inputs if any Matrix.setMatrixCallAudioInput(audioDeviceId);
Matrix.setMatrixCallAudioInput(localSettings['webrtc_audioinput']); Matrix.setMatrixCallVideoInput(videoDeviceId);
Matrix.setMatrixCallVideoInput(localSettings['webrtc_videoinput']);
// });
}, },
setAudioInput: function(deviceId) { setAudioInput: function(deviceId) {
UserSettingsStore.setLocalSetting('webrtc_audioinput', deviceId); SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallAudioInput(deviceId); Matrix.setMatrixCallAudioInput(deviceId);
}, },
setVideoInput: function(deviceId) { setVideoInput: function(deviceId) {
UserSettingsStore.setLocalSetting('webrtc_videoinput', deviceId); SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
Matrix.setMatrixCallVideoInput(deviceId); Matrix.setMatrixCallVideoInput(deviceId);
}, },
}; };

View file

@ -61,7 +61,7 @@ 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( this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)), Object.assign(new HistoryItem(), JSON.parse(item)),
); );

View file

@ -99,23 +99,17 @@ function loadImageElement(imageFile) {
// Load the file into an html element // Load the file into an html element
const img = document.createElement("img"); const img = document.createElement("img");
const objectUrl = URL.createObjectURL(imageFile);
const reader = new FileReader(); img.src = objectUrl;
reader.onload = function(e) {
img.src = e.target.result;
// Once ready, create a thumbnail // Once ready, create a thumbnail
img.onload = function() { img.onload = function() {
URL.revokeObjectURL(objectUrl);
deferred.resolve(img); deferred.resolve(img);
}; };
img.onerror = function(e) { img.onerror = function(e) {
deferred.reject(e); deferred.reject(e);
}; };
};
reader.onerror = function(e) {
deferred.reject(e);
};
reader.readAsDataURL(imageFile);
return deferred.promise; return deferred.promise;
} }

View file

@ -22,12 +22,22 @@ import MatrixClientPeg from './MatrixClientPeg';
import GroupStoreCache from './stores/GroupStoreCache'; import GroupStoreCache from './stores/GroupStoreCache';
export function showGroupInviteDialog(groupId) { export function showGroupInviteDialog(groupId) {
const description = <div>
<div>{ _t("Who would you like to add to this community?") }</div>
<div className="warning">
{ _t(
"Warning: any person you add to a community will be publicly "+
"visible to anyone who knows the community ID",
) }
</div>
</div>;
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, { Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, {
title: _t("Invite new group members"), title: _t("Invite new community members"),
description: _t("Who would you like to add to this group?"), description: description,
placeholder: _t("Name or matrix ID"), placeholder: _t("Name or matrix ID"),
button: _t("Invite to Group"), button: _t("Invite to Community"),
validAddressTypes: ['mx-user-id'], validAddressTypes: ['mx-user-id'],
onFinished: (success, addrs) => { onFinished: (success, addrs) => {
if (!success) return; if (!success) return;
@ -39,18 +49,34 @@ export function showGroupInviteDialog(groupId) {
export function showGroupAddRoomDialog(groupId) { export function showGroupAddRoomDialog(groupId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let addRoomsPublicly = false;
const onCheckboxClicked = (e) => {
addRoomsPublicly = e.target.checked;
};
const description = <div>
<div>{ _t("Which rooms would you like to add to this community?") }</div>
</div>;
const checkboxContainer = <label className="mx_GroupAddressPicker_checkboxContainer">
<input type="checkbox" onClick={onCheckboxClicked} />
<div>
{ _t("Show these rooms to non-members on the community page and room list?") }
</div>
</label>;
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, { Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, {
title: _t("Add rooms to the group"), title: _t("Add rooms to the community"),
description: _t("Which rooms would you like to add to this group?"), description: description,
extraNode: checkboxContainer,
placeholder: _t("Room name or alias"), placeholder: _t("Room name or alias"),
button: _t("Add to group"), button: _t("Add to community"),
pickerType: 'room', pickerType: 'room',
validAddressTypes: ['mx-room-id'], validAddressTypes: ['mx-room-id'],
onFinished: (success, addrs) => { onFinished: (success, addrs) => {
if (!success) return; if (!success) return;
_onGroupAddRoomFinished(groupId, addrs).then(resolve, reject); _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly).then(resolve, reject);
}, },
}); });
}); });
@ -80,20 +106,37 @@ function _onGroupInviteFinished(groupId, addrs) {
}).catch((err) => { }).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, { Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, {
title: _t("Failed to invite users group"), title: _t("Failed to invite users to community"),
description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}), description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}),
}); });
}); });
} }
function _onGroupAddRoomFinished(groupId, addrs) { function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); const matrixClient = MatrixClientPeg.get();
const groupStore = GroupStoreCache.getGroupStore(groupId);
const errorList = []; const errorList = [];
return Promise.all(addrs.map((addr) => { return Promise.all(addrs.map((addr) => {
return groupStore return groupStore
.addRoomToGroup(addr.address) .addRoomToGroup(addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); }) .catch(() => { errorList.push(addr.address); })
.reflect(); .then(() => {
const roomId = addr.address;
const room = matrixClient.getRoom(roomId);
// Can the user change related groups?
if (!room || !room.currentState.mayClientSendStateEvent("m.room.related_groups", matrixClient)) {
return;
}
// Get the related groups
const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', '');
const groups = relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [];
// Add this group as related
if (!groups.includes(groupId)) {
groups.push(groupId);
return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, '');
}
}).reflect();
})).then(() => { })).then(() => {
if (errorList.length === 0) { if (errorList.length === 0) {
return; return;

View file

@ -172,7 +172,7 @@ const sanitizeHtmlParams = {
// Lots of these won't come up by default because we don't allow them // Lots of these won't come up by default because we don't allow them
selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'],
// URL schemes we permit // URL schemes we permit
allowedSchemes: ['http', 'https', 'ftp', 'mailto'], allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'],
allowProtocolRelative: false, allowProtocolRelative: false,
@ -208,7 +208,7 @@ const sanitizeHtmlParams = {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // we don't want to allow images with `https?` `src`s.
if (!attribs.src.startsWith('mxc://')) { if (!attribs.src || !attribs.src.startsWith('mxc://')) {
return { tagName, attribs: {}}; return { tagName, attribs: {}};
} }
attribs.src = MatrixClientPeg.get().mxcUrlToHttp( attribs.src = MatrixClientPeg.get().mxcUrlToHttp(
@ -385,10 +385,9 @@ class TextHighlighter extends BaseHighlighter {
* 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.
*/ */
export function bodyToHtml(content, highlights, opts) { export function bodyToHtml(content, highlights, opts={}) {
opts = opts || {};
const isHtml = (content.format === "org.matrix.custom.html"); const isHtml = (content.format === "org.matrix.custom.html");
const body = isHtml ? content.formatted_body : escape(content.body); const body = isHtml ? content.formatted_body : escape(content.body);
@ -418,7 +417,7 @@ export function bodyToHtml(content, highlights, opts) {
} }
let emojiBody = false; let emojiBody = false;
if (bodyHasEmoji) { if (!opts.disableBigEmoji && bodyHasEmoji) {
EMOJI_REGEX.lastIndex = 0; EMOJI_REGEX.lastIndex = 0;
const contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; const contentBodyTrimmed = content.body !== undefined ? content.body.trim() : '';
const match = EMOJI_REGEX.exec(contentBodyTrimmed); const match = EMOJI_REGEX.exec(contentBodyTrimmed);

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2017 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,7 +16,7 @@ limitations under the License.
*/ */
/* a selection of key codes, as used in KeyboardEvent.keyCode */ /* a selection of key codes, as used in KeyboardEvent.keyCode */
module.exports = { export const KeyCode = {
BACKSPACE: 8, BACKSPACE: 8,
TAB: 9, TAB: 9,
ENTER: 13, ENTER: 13,
@ -58,3 +59,12 @@ module.exports = {
KEY_Y: 89, KEY_Y: 89,
KEY_Z: 90, KEY_Z: 90,
}; };
export function isOnlyCtrlOrCmdKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
}

View file

@ -389,6 +389,8 @@ function _persistCredentialsToLocalStorage(credentials) {
* Logs the current session out and transitions to the logged-out state * Logs the current session out and transitions to the logged-out state
*/ */
export function logout() { export function logout() {
if (!MatrixClientPeg.get()) return;
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions // logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session // Also we sometimes want to re-log in a guest session
@ -436,6 +438,10 @@ function startMatrixClient() {
DMRoomMap.makeShared().start(); DMRoomMap.makeShared().start();
MatrixClientPeg.start(); MatrixClientPeg.start();
// dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up.
dis.dispatch({action: 'client_started'});
} }
/* /*

View file

@ -143,17 +143,8 @@ export default class Login {
Object.assign(loginParams, legacyParams); Object.assign(loginParams, legacyParams);
const client = this._createTemporaryClient(); const client = this._createTemporaryClient();
return client.login('m.login.password', loginParams).then(function(data) {
return Promise.resolve({ const tryFallbackHs = (originalError) => {
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
});
}, function(error) {
if (error.httpStatus === 403) {
if (self._fallbackHsUrl) {
const fbClient = Matrix.createClient({ const fbClient = Matrix.createClient({
baseUrl: self._fallbackHsUrl, baseUrl: self._fallbackHsUrl,
idBaseUrl: this._isUrl, idBaseUrl: this._isUrl,
@ -167,12 +158,69 @@ export default class Login {
deviceId: data.device_id, deviceId: data.device_id,
accessToken: data.access_token, accessToken: data.access_token,
}); });
}, function(fallback_error) { }).catch((fallback_error) => {
console.log("fallback HS login failed", fallback_error);
// throw the original error // throw the original error
throw error; throw originalError;
}); });
};
const tryLowercaseUsername = (originalError) => {
const loginParamsLowercase = Object.assign({}, loginParams, {
user: username.toLowerCase(),
identifier: {
user: username.toLowerCase(),
},
});
return client.login('m.login.password', loginParamsLowercase).then(function(data) {
return Promise.resolve({
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
});
}).catch((fallback_error) => {
console.log("Lowercase username login failed", fallback_error);
// throw the original error
throw originalError;
});
};
let originalLoginError = null;
return client.login('m.login.password', loginParams).then(function(data) {
return Promise.resolve({
homeserverUrl: self._hsUrl,
identityServerUrl: self._isUrl,
userId: data.user_id,
deviceId: data.device_id,
accessToken: data.access_token,
});
}).catch((error) => {
originalLoginError = error;
if (error.httpStatus === 403) {
if (self._fallbackHsUrl) {
return tryFallbackHs(originalLoginError);
} }
} }
throw originalLoginError;
}).catch((error) => {
// We apparently squash case at login serverside these days:
// https://github.com/matrix-org/synapse/blob/1189be43a2479f5adf034613e8d10e3f4f452eb9/synapse/handlers/auth.py#L475
// so this wasn't needed after all. Keeping the code around in case the
// the situation changes...
/*
if (
error.httpStatus === 403 &&
loginParams.identifier.type === 'm.id.user' &&
username.search(/[A-Z]/) > -1
) {
return tryLowercaseUsername(originalLoginError);
}
*/
throw originalLoginError;
}).catch((error) => {
console.log("Login failed", error);
throw error; throw error;
}); });
} }

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 2017 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.
@ -21,6 +22,8 @@ import utils from 'matrix-js-sdk/lib/utils';
import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline'; import EventTimeline from 'matrix-js-sdk/lib/models/event-timeline';
import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set'; import EventTimelineSet from 'matrix-js-sdk/lib/models/event-timeline-set';
import createMatrixClient from './utils/createMatrixClient'; import createMatrixClient from './utils/createMatrixClient';
import SettingsStore from './settings/SettingsStore';
import MatrixActionCreators from './actions/MatrixActionCreators';
interface MatrixClientCreds { interface MatrixClientCreds {
homeserverUrl: string, homeserverUrl: string,
@ -67,6 +70,8 @@ class MatrixClientPeg {
unset() { unset() {
this.matrixClient = null; this.matrixClient = null;
MatrixActionCreators.stop();
} }
/** /**
@ -84,7 +89,7 @@ class MatrixClientPeg {
if (this.matrixClient.initCrypto) { if (this.matrixClient.initCrypto) {
await this.matrixClient.initCrypto(); await this.matrixClient.initCrypto();
} }
} catch(e) { } catch (e) {
// this can happen for a number of reasons, the most likely being // this can happen for a number of reasons, the most likely being
// that the olm library was missing. It's not fatal. // that the olm library was missing. It's not fatal.
console.warn("Unable to initialise e2e: " + e); console.warn("Unable to initialise e2e: " + e);
@ -93,12 +98,13 @@ class MatrixClientPeg {
const opts = utils.deepCopy(this.opts); const opts = utils.deepCopy(this.opts);
// 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";
opts.disablePresence = true; // we do this manually
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}`);
} }
@ -106,6 +112,9 @@ class MatrixClientPeg {
// 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
// just end up doing a full initial /sync. // just end up doing a full initial /sync.
// Connect the matrix client to the dispatcher
MatrixActionCreators.start(this.matrixClient);
console.log(`MatrixClientPeg: really starting MatrixClient`); console.log(`MatrixClientPeg: really starting MatrixClient`);
this.get().startClient(opts); this.get().startClient(opts);
console.log(`MatrixClientPeg: MatrixClient started`); console.log(`MatrixClientPeg: MatrixClient started`);
@ -143,6 +152,7 @@ class MatrixClientPeg {
userId: creds.userId, userId: creds.userId,
deviceId: creds.deviceId, deviceId: creds.deviceId,
timelineSupport: true, timelineSupport: true,
forceTURN: SettingsStore.getValue('webRtcForceTURN', false),
}; };
this.matrixClient = createMatrixClient(opts, this.indexedDbWorkerScript); this.matrixClient = createMatrixClient(opts, this.indexedDbWorkerScript);

View file

@ -25,6 +25,7 @@ import dis from './dispatcher';
import sdk from './index'; import sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import Modal from './Modal'; import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
/* /*
* Dispatches: * Dispatches:
@ -80,10 +81,11 @@ const Notifier = {
if (ev.getContent().body) msg = ev.getContent().body; if (ev.getContent().body) msg = ev.getContent().body;
} }
const avatarUrl = ev.sender ? Avatar.avatarUrlForMember( if (!this.isBodyEnabled()) {
ev.sender, 40, 40, 'crop', msg = '';
) : null; }
const avatarUrl = ev.sender ? Avatar.avatarUrlForMember(ev.sender, 40, 40, 'crop') : null;
const notif = plaf.displayNotification(title, msg, avatarUrl, room); const notif = plaf.displayNotification(title, msg, avatarUrl, room);
// if displayNotification returns non-null, the platform supports // if displayNotification returns non-null, the platform supports
@ -137,10 +139,8 @@ const Notifier = {
// make sure that we persist the current setting audio_enabled setting // make sure that we persist the current setting audio_enabled setting
// before changing anything // before changing anything
if (global.localStorage) { if (SettingsStore.isLevelSupported(SettingLevel.DEVICE)) {
if (global.localStorage.getItem('audio_notifications_enabled') === null) { SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, this.isEnabled());
this.setAudioEnabled(this.isEnabled());
}
} }
if (enable) { if (enable) {
@ -148,6 +148,7 @@ const Notifier = {
plaf.requestNotificationPermission().done((result) => { plaf.requestNotificationPermission().done((result) => {
if (result !== 'granted') { if (result !== 'granted') {
// The permission request was dismissed or denied // The permission request was dismissed or denied
// TODO: Support alternative branding in messaging
const description = result === 'denied' const description = result === 'denied'
? _t('Riot does not have permission to send you notifications - please check your browser settings') ? _t('Riot does not have permission to send you notifications - please check your browser settings')
: _t('Riot was not given permission to send notifications - please try again'); : _t('Riot was not given permission to send notifications - please try again');
@ -159,10 +160,6 @@ const Notifier = {
return; return;
} }
if (global.localStorage) {
global.localStorage.setItem('notifications_enabled', 'true');
}
if (callback) callback(); if (callback) callback();
dis.dispatch({ dis.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
@ -173,8 +170,6 @@ const Notifier = {
// disabled again in the future, we will show the banner again. // disabled again in the future, we will show the banner again.
this.setToolbarHidden(false); this.setToolbarHidden(false);
} else { } else {
if (!global.localStorage) return;
global.localStorage.setItem('notifications_enabled', 'false');
dis.dispatch({ dis.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
value: false, value: false,
@ -183,31 +178,24 @@ const Notifier = {
}, },
isEnabled: function() { isEnabled: function() {
return this.isPossible() && SettingsStore.getValue("notificationsEnabled");
},
isPossible: function() {
const plaf = PlatformPeg.get(); const plaf = PlatformPeg.get();
if (!plaf) return false; if (!plaf) return false;
if (!plaf.supportsNotifications()) return false; if (!plaf.supportsNotifications()) return false;
if (!plaf.maySendNotifications()) return false; if (!plaf.maySendNotifications()) return false;
if (!global.localStorage) return true; return true; // possible, but not necessarily enabled
const enabled = global.localStorage.getItem('notifications_enabled');
if (enabled === null) return true;
return enabled === 'true';
}, },
setAudioEnabled: function(enable) { isBodyEnabled: function() {
if (!global.localStorage) return; return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled");
global.localStorage.setItem('audio_notifications_enabled',
enable ? 'true' : 'false');
}, },
isAudioEnabled: function(enable) { isAudioEnabled: function() {
if (!global.localStorage) return true; return this.isEnabled() && SettingsStore.getValue("audioNotificationsEnabled");
const enabled = global.localStorage.getItem(
'audio_notifications_enabled');
// default to true if the popups are enabled
if (enabled === null) return this.isEnabled();
return enabled === 'true';
}, },
setToolbarHidden: function(hidden, persistent = true) { setToolbarHidden: function(hidden, persistent = true) {
@ -224,16 +212,14 @@ const Notifier = {
// update the info to localStorage for persistent settings // update the info to localStorage for persistent settings
if (persistent && global.localStorage) { if (persistent && global.localStorage) {
global.localStorage.setItem('notifications_hidden', hidden); global.localStorage.setItem("notifications_hidden", hidden);
} }
}, },
isToolbarHidden: function() { isToolbarHidden: function() {
// Check localStorage for any such meta data // Check localStorage for any such meta data
if (global.localStorage) { if (global.localStorage) {
if (global.localStorage.getItem('notifications_hidden') === 'true') { return global.localStorage.getItem("notifications_hidden") === "true";
return true;
}
} }
return this.toolbarHidden; return this.toolbarHidden;

View file

@ -56,13 +56,27 @@ class Presence {
return this.state; return this.state;
} }
/**
* Get the current status message.
* @returns {String} the status message, may be null
*/
getStatusMessage() {
return this.statusMessage;
}
/** /**
* Set the presence state. * Set the presence state.
* If the state has changed, the Home Server will be notified. * If the state has changed, the Home Server will be notified.
* @param {string} newState the new presence state (see PRESENCE enum) * @param {string} newState the new presence state (see PRESENCE enum)
* @param {String} statusMessage an optional status message for the presence
* @param {boolean} maintain true to have this status maintained by this tracker
*/ */
setState(newState) { setState(newState, statusMessage=null, maintain=false) {
if (newState === this.state) { if (this.maintain) {
// Don't update presence if we're maintaining a particular status
return;
}
if (newState === this.state && statusMessage === this.statusMessage) {
return; return;
} }
if (PRESENCE_STATES.indexOf(newState) === -1) { if (PRESENCE_STATES.indexOf(newState) === -1) {
@ -72,21 +86,37 @@ class Presence {
return; return;
} }
const old_state = this.state; const old_state = this.state;
const old_message = this.statusMessage;
this.state = newState; this.state = newState;
this.statusMessage = statusMessage;
this.maintain = maintain;
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
return; // don't try to set presence when a guest; it won't work. return; // don't try to set presence when a guest; it won't work.
} }
const updateContent = {
presence: this.state,
status_msg: this.statusMessage ? this.statusMessage : '',
};
const self = this; const self = this;
MatrixClientPeg.get().setPresence(this.state).done(function() { MatrixClientPeg.get().setPresence(updateContent).done(function() {
console.log("Presence: %s", newState); console.log("Presence: %s", newState);
// We have to dispatch because the js-sdk is unreliable at telling us about our own presence
dis.dispatch({action: "self_presence_updated", statusInfo: updateContent});
}, function(err) { }, function(err) {
console.error("Failed to set presence: %s", err); console.error("Failed to set presence: %s", err);
self.state = old_state; self.state = old_state;
self.statusMessage = old_message;
}); });
} }
stopMaintainingStatus() {
this.maintain = false;
}
/** /**
* Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
* @private * @private
@ -95,7 +125,8 @@ class Presence {
this.setState("unavailable"); this.setState("unavailable");
} }
_onUserActivity() { _onUserActivity(payload) {
if (payload.action === "sync_state" || payload.action === "self_presence_updated") return;
this._resetTimer(); this._resetTimer();
} }

View file

@ -44,13 +44,6 @@ module.exports = {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148 // https://github.com/vector-im/riot-web/issues/3148
console.log('Resend got send failure: ' + err.name + '('+err+')'); console.log('Resend got send failure: ' + err.name + '('+err+')');
if (err.name === "UnknownDeviceError") {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: room,
});
}
dis.dispatch({ dis.dispatch({
action: 'message_send_failed', action: 'message_send_failed',
@ -60,9 +53,5 @@ module.exports = {
}, },
removeFromQueue: function(event) { removeFromQueue: function(event) {
MatrixClientPeg.get().cancelPendingEvent(event); MatrixClientPeg.get().cancelPendingEvent(event);
dis.dispatch({
action: 'message_send_cancelled',
event: event,
});
}, },
}; };

View file

@ -68,7 +68,7 @@ function unicodeToEmojiUri(str) {
return unicodeChar; return unicodeChar;
} else { } else {
// Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below // Remove variant selector VS16 (explicitly emoji) as it is unnecessary and leads to an incorrect URL below
if(unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') { if (unicodeChar.length == 2 && unicodeChar[1] == '\ufe0f') {
unicodeChar = unicodeChar[0]; unicodeChar = unicodeChar[0];
} }

View file

@ -15,19 +15,20 @@ limitations under the License.
*/ */
import { _t } from './languageHandler'; import { _t } from './languageHandler';
export function levelRoleMap() { export function levelRoleMap(usersDefault) {
return { return {
undefined: _t('Default'), undefined: _t('Default'),
0: _t('User'), 0: _t('Restricted'),
[usersDefault]: _t('Default'),
50: _t('Moderator'), 50: _t('Moderator'),
100: _t('Admin'), 100: _t('Admin'),
}; };
} }
export function textualPowerLevel(level, userDefault) { export function textualPowerLevel(level, usersDefault) {
const LEVEL_ROLE_MAP = this.levelRoleMap(); const LEVEL_ROLE_MAP = this.levelRoleMap(usersDefault);
if (LEVEL_ROLE_MAP[level]) { if (LEVEL_ROLE_MAP[level]) {
return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${userDefault})`); return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`);
} else { } else {
return level; return level;
} }

View file

@ -21,6 +21,8 @@ import Modal from './Modal';
import { getAddressType } from './UserAddress'; import { getAddressType } from './UserAddress';
import createRoom from './createRoom'; import createRoom from './createRoom';
import sdk from './'; import sdk from './';
import dis from './dispatcher';
import DMRoomMap from './utils/DMRoomMap';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
export function inviteToRoom(roomId, addr) { export function inviteToRoom(roomId, addr) {
@ -79,15 +81,40 @@ function _onStartChatFinished(shouldInvite, addrs) {
const addrTexts = addrs.map((addr) => addr.address); const addrTexts = addrs.map((addr) => addr.address);
if (_isDmChat(addrTexts)) { if (_isDmChat(addrTexts)) {
const rooms = _getDirectMessageRooms(addrTexts[0]);
if (rooms.length > 0) {
// A Direct Message room already exists for this user, so select a
// room from a list that is similar to the one in MemberInfo panel
const ChatCreateOrReuseDialog = sdk.getComponent(
"views.dialogs.ChatCreateOrReuseDialog",
);
const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, {
userId: addrTexts[0],
onNewDMClick: () => {
dis.dispatch({
action: 'start_chat',
user_id: addrTexts[0],
});
close(true);
},
onExistingRoomSelected: (roomId) => {
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
close(true);
},
}).close;
} else {
// Start a new DM chat // Start a new DM chat
createRoom({dmUserId: addrTexts[0]}).catch((err) => { createRoom({dmUserId: addrTexts[0]}).catch((err) => {
console.error(err.stack);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, {
title: _t("Failed to invite user"), title: _t("Failed to invite user"),
description: ((err && err.message) ? err.message : _t("Operation failed")), description: ((err && err.message) ? err.message : _t("Operation failed")),
}); });
}); });
}
} else { } else {
// Start multi user chat // Start multi user chat
let room; let room;
@ -127,7 +154,7 @@ function _onRoomInviteFinished(roomId, shouldInvite, addrs) {
} }
function _isDmChat(addrTexts) { function _isDmChat(addrTexts) {
if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx') { if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx-user-id') {
return true; return true;
} else { } else {
return false; return false;
@ -153,3 +180,19 @@ function _showAnyInviteErrors(addrs, room) {
return addrs; return addrs;
} }
function _getDirectMessageRooms(addr) {
const dmRoomMap = new DMRoomMap(MatrixClientPeg.get());
const dmRooms = dmRoomMap.getDMRoomsForUserId(addr);
const rooms = [];
dmRooms.forEach((dmRoom) => {
const room = MatrixClientPeg.get().getRoom(dmRoom);
if (room) {
const me = room.getMember(MatrixClientPeg.get().credentials.userId);
if (me.membership == 'join') {
rooms.push(room);
}
}
});
return rooms;
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import Promise from 'bluebird'; import Promise from 'bluebird';
import SettingsStore from "./settings/SettingsStore";
const request = require('browser-request'); const request = require('browser-request');
const SdkConfig = require('./SdkConfig'); const SdkConfig = require('./SdkConfig');
@ -76,10 +77,40 @@ class ScalarAuthClient {
return defer.promise; return defer.promise;
} }
getScalarPageTitle(url) {
const defer = Promise.defer();
let scalarPageLookupUrl = SdkConfig.get().integrations_rest_url + '/widgets/title_lookup';
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl);
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;
}
getScalarInterfaceUrlForRoom(roomId, screen, id) { getScalarInterfaceUrlForRoom(roomId, screen, id) {
let url = SdkConfig.get().integrations_ui_url; let url = SdkConfig.get().integrations_ui_url;
url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "?scalar_token=" + encodeURIComponent(this.scalarToken);
url += "&room_id=" + encodeURIComponent(roomId); url += "&room_id=" + encodeURIComponent(roomId);
url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme"));
if (id) { if (id) {
url += '&integ_id=' + encodeURIComponent(id); url += '&integ_id=' + encodeURIComponent(id);
} }

View file

@ -366,6 +366,22 @@ function getWidgets(event, roomId) {
sendResponse(event, widgetStateEvents); sendResponse(event, widgetStateEvents);
} }
function getRoomEncState(event, roomId) {
const client = MatrixClientPeg.get();
if (!client) {
sendError(event, _t('You need to be logged in.'));
return;
}
const room = client.getRoom(roomId);
if (!room) {
sendError(event, _t('This room is not recognised.'));
return;
}
const roomIsEncrypted = MatrixClientPeg.get().isRoomEncrypted(roomId);
sendResponse(event, roomIsEncrypted);
}
function setPlumbingState(event, roomId, status) { function setPlumbingState(event, roomId, status) {
if (typeof status !== 'string') { if (typeof status !== 'string') {
throw new Error('Plumbing state status should be a string'); throw new Error('Plumbing state status should be a string');
@ -541,8 +557,16 @@ const onMessage = function(event) {
// //
// All strings start with the empty string, so for sanity return if the length // All strings start with the empty string, so for sanity return if the length
// of the event origin is 0. // of the event origin is 0.
//
// TODO -- Scalar postMessage API should be namespaced with event.data.api field
// Fix following "if" statement to respond only to specific API messages.
const url = SdkConfig.get().integrations_ui_url; const url = SdkConfig.get().integrations_ui_url;
if (event.origin.length === 0 || !url.startsWith(event.origin) || !event.data.action) { if (
event.origin.length === 0 ||
!url.startsWith(event.origin) ||
!event.data.action ||
event.data.api // Ignore messages with specific API set
) {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
} }
@ -593,6 +617,9 @@ const onMessage = function(event) {
} else if (event.data.action === "get_widgets") { } else if (event.data.action === "get_widgets") {
getWidgets(event, roomId); getWidgets(event, roomId);
return; return;
} else if (event.data.action === "get_room_enc_state") {
getRoomEncState(event, roomId);
return;
} else if (event.data.action === "can_send_event") { } else if (event.data.action === "can_send_event") {
canSendEvent(event, roomId); canSendEvent(event, roomId);
return; return;

View file

@ -26,7 +26,7 @@ const DEFAULTS = {
class SdkConfig { class SdkConfig {
static get() { static get() {
return global.mxReactSdkConfig; return global.mxReactSdkConfig || {};
} }
static put(cfg) { static put(cfg) {

View file

@ -20,6 +20,7 @@ import Tinter from "./Tinter";
import sdk from './index'; import sdk from './index';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import Modal from './Modal'; import Modal from './Modal';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
class Command { class Command {
@ -97,9 +98,7 @@ const commands = {
colorScheme.secondary_color = matches[4]; colorScheme.secondary_color = matches[4];
} }
return success( return success(
MatrixClientPeg.get().setRoomAccountData( SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
roomId, "org.matrix.room.color_scheme", colorScheme,
),
); );
} }
} }

View file

@ -151,9 +151,9 @@ function textForCallHangupEvent(event) {
const senderName = event.sender ? event.sender.name : _t('Someone'); const senderName = event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent(); const eventContent = event.getContent();
let reason = ""; let reason = "";
if(!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
reason = _t('(not supported by this browser)'); reason = _t('(not supported by this browser)');
} else if(eventContent.reason) { } else if (eventContent.reason) {
if (eventContent.reason === "ice_failed") { if (eventContent.reason === "ice_failed") {
reason = _t('(could not connect media)'); reason = _t('(could not connect media)');
} else if (eventContent.reason === "invite_timeout") { } else if (eventContent.reason === "invite_timeout") {
@ -259,6 +259,11 @@ function textForPowerEvent(event) {
}); });
} }
function textForPinnedEvent(event) {
const senderName = event.getSender();
return _t("%(senderName)s changed the pinned messages for the room.", {senderName});
}
function textForWidgetEvent(event) { function textForWidgetEvent(event) {
const senderName = event.getSender(); const senderName = event.getSender();
const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
@ -304,6 +309,7 @@ const stateHandlers = {
'm.room.history_visibility': textForHistoryVisibilityEvent, 'm.room.history_visibility': textForHistoryVisibilityEvent,
'm.room.encryption': textForEncryptionEvent, 'm.room.encryption': textForEncryptionEvent,
'm.room.power_levels': textForPowerEvent, 'm.room.power_levels': textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent,
'im.vector.modular.widgets': textForWidgetEvent, 'im.vector.modular.widgets': textForWidgetEvent,
}; };

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015 OpenMarket Ltd Copyright 2015 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,70 +15,288 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// FIXME: these vars should be bundled up and attached to
// module.exports otherwise this will break when included by both
// react-sdk and apps layered on top.
const DEBUG = 0; const DEBUG = 0;
// The colour keys to be replaced as referred to in CSS // utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue]
const keyRgb = [ function colorToRgb(color) {
if (!color) {
return [0, 0, 0];
}
if (color[0] === '#') {
color = color.slice(1);
if (color.length === 3) {
color = color[0] + color[0] +
color[1] + color[1] +
color[2] + color[2];
}
const val = parseInt(color, 16);
const r = (val >> 16) & 255;
const g = (val >> 8) & 255;
const b = val & 255;
return [r, g, b];
} else {
const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/);
if (match) {
return [
parseInt(match[1]),
parseInt(match[2]),
parseInt(match[3]),
];
}
}
return [0, 0, 0];
}
// utility to turn [red,green,blue] into #rrggbb
function rgbToColor(rgb) {
const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
return '#' + (0x1000000 + val).toString(16).slice(1);
}
class Tinter {
constructor() {
// The default colour keys to be replaced as referred to in CSS
// (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor)
this.keyRgb = [
"rgb(118, 207, 166)", // Vector Green "rgb(118, 207, 166)", // Vector Green
"rgb(234, 245, 240)", // Vector Light Green "rgb(234, 245, 240)", // Vector Light Green
"rgb(211, 239, 225)", // BottomLeftMenu overlay (20% Vector Green) "rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
]; ];
// Some algebra workings for calculating the tint % of Vector Green & Light Green // Some algebra workings for calculating the tint % of Vector Green & Light Green
// x * 118 + (1 - x) * 255 = 234 // x * 118 + (1 - x) * 255 = 234
// x * 118 + 255 - 255 * x = 234 // x * 118 + 255 - 255 * x = 234
// x * 118 - x * 255 = 234 - 255 // x * 118 - x * 255 = 234 - 255
// (255 - 118) x = 255 - 234 // (255 - 118) x = 255 - 234
// x = (255 - 234) / (255 - 118) = 0.16 // x = (255 - 234) / (255 - 118) = 0.16
// The colour keys to be replaced as referred to in SVGs // The colour keys to be replaced as referred to in SVGs
const keyHex = [ this.keyHex = [
"#76CFA6", // Vector Green "#76CFA6", // Vector Green
"#EAF5F0", // Vector Light Green "#EAF5F0", // Vector Light Green
"#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on Vector Light Green) "#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green)
"#FFFFFF", // white highlights of the SVGs (for switching to dark theme) "#FFFFFF", // white highlights of the SVGs (for switching to dark theme)
]; "#000000", // black lowlights of the SVGs (for switching to dark theme)
];
// cache of our replacement colours // track the replacement colours actually being used
// defaults to our keys. // defaults to our keys.
const colors = [ this.colors = [
keyHex[0], this.keyHex[0],
keyHex[1], this.keyHex[1],
keyHex[2], this.keyHex[2],
keyHex[3], this.keyHex[3],
]; this.keyHex[4],
];
const cssFixups = [ // track the most current tint request inputs (which may differ from the
// { // end result stored in this.colors
this.currentTint = [
undefined,
undefined,
undefined,
undefined,
undefined,
];
this.cssFixups = [
// { theme: {
// style: a style object that should be fixed up taken from a stylesheet // style: a style object that should be fixed up taken from a stylesheet
// attr: name of the attribute to be clobbered, e.g. 'color' // attr: name of the attribute to be clobbered, e.g. 'color'
// index: ordinal of primary, secondary or tertiary // index: ordinal of primary, secondary or tertiary
// },
// } // }
]; ];
// CSS attributes to be fixed up // CSS attributes to be fixed up
const cssAttrs = [ this.cssAttrs = [
"color", "color",
"backgroundColor", "backgroundColor",
"borderColor", "borderColor",
"borderTopColor", "borderTopColor",
"borderBottomColor", "borderBottomColor",
"borderLeftColor", "borderLeftColor",
]; ];
const svgAttrs = [ this.svgAttrs = [
"fill", "fill",
"stroke", "stroke",
]; ];
let cached = false; // List of functions to call when the tint changes.
this.tintables = [];
// the currently loaded theme (if any)
this.theme = undefined;
// whether to force a tint (e.g. after changing theme)
this.forceTint = false;
}
/**
* Register a callback to fire when the tint changes.
* This is used to rewrite the tintable SVGs with the new tint.
*
* It's not possible to unregister a tintable callback. So this can only be
* used to register a static callback. If a set of tintables will change
* over time then the best bet is to register a single callback for the
* entire set.
*
* @param {Function} tintable Function to call when the tint changes.
*/
registerTintable(tintable) {
this.tintables.push(tintable);
}
getKeyRgb() {
return this.keyRgb;
}
tint(primaryColor, secondaryColor, tertiaryColor) {
this.currentTint[0] = primaryColor;
this.currentTint[1] = secondaryColor;
this.currentTint[2] = tertiaryColor;
this.calcCssFixups();
if (DEBUG) {
console.log("Tinter.tint(" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
if (!primaryColor) {
primaryColor = this.keyRgb[0];
secondaryColor = this.keyRgb[1];
tertiaryColor = this.keyRgb[2];
}
if (!secondaryColor) {
const x = 0.16; // average weighting factor calculated from vector green & light green
const rgb = colorToRgb(primaryColor);
rgb[0] = x * rgb[0] + (1 - x) * 255;
rgb[1] = x * rgb[1] + (1 - x) * 255;
rgb[2] = x * rgb[2] + (1 - x) * 255;
secondaryColor = rgbToColor(rgb);
}
if (!tertiaryColor) {
const x = 0.19;
const rgb1 = colorToRgb(primaryColor);
const rgb2 = colorToRgb(secondaryColor);
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1];
rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2];
tertiaryColor = rgbToColor(rgb1);
}
if (this.forceTint == false &&
this.colors[0] === primaryColor &&
this.colors[1] === secondaryColor &&
this.colors[2] === tertiaryColor) {
return;
}
this.forceTint = false;
this.colors[0] = primaryColor;
this.colors[1] = secondaryColor;
this.colors[2] = tertiaryColor;
if (DEBUG) {
console.log("Tinter.tint final: (" + primaryColor + ", " +
secondaryColor + ", " +
tertiaryColor + ")");
}
// go through manually fixing up the stylesheets.
this.applyCssFixups();
// tell all the SVGs to go fix themselves up
// we don't do this as a dispatch otherwise it will visually lag
this.tintables.forEach(function(tintable) {
tintable();
});
}
tintSvgWhite(whiteColor) {
this.currentTint[3] = whiteColor;
if (!whiteColor) {
whiteColor = this.colors[3];
}
if (this.colors[3] === whiteColor) {
return;
}
this.colors[3] = whiteColor;
this.tintables.forEach(function(tintable) {
tintable();
});
}
tintSvgBlack(blackColor) {
this.currentTint[4] = blackColor;
if (!blackColor) {
blackColor = this.colors[4];
}
if (this.colors[4] === blackColor) {
return;
}
this.colors[4] = blackColor;
this.tintables.forEach(function(tintable) {
tintable();
});
}
setTheme(theme) {
console.trace("setTheme " + theme);
this.theme = theme;
// update keyRgb from the current theme CSS itself, if it defines it
if (document.getElementById('mx_theme_accentColor')) {
this.keyRgb[0] = window.getComputedStyle(
document.getElementById('mx_theme_accentColor')).color;
}
if (document.getElementById('mx_theme_secondaryAccentColor')) {
this.keyRgb[1] = window.getComputedStyle(
document.getElementById('mx_theme_secondaryAccentColor')).color;
}
if (document.getElementById('mx_theme_tertiaryAccentColor')) {
this.keyRgb[2] = window.getComputedStyle(
document.getElementById('mx_theme_tertiaryAccentColor')).color;
}
this.calcCssFixups();
this.forceTint = true;
this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]);
if (theme === 'dark') {
// abuse the tinter to change all the SVG's #fff to #2d2d2d
// XXX: obviously this shouldn't be hardcoded here.
this.tintSvgWhite('#2d2d2d');
this.tintSvgBlack('#dddddd');
} else {
this.tintSvgWhite('#ffffff');
this.tintSvgBlack('#000000');
}
}
calcCssFixups() {
// cache our fixups
if (this.cssFixups[this.theme]) return;
if (DEBUG) {
console.debug("calcCssFixups start for " + this.theme + " (checking " +
document.styleSheets.length +
" stylesheets)");
}
this.cssFixups[this.theme] = [];
function calcCssFixups() {
if (DEBUG) console.log("calcSvgFixups start");
for (let i = 0; i < document.styleSheets.length; i++) { for (let i = 0; i < document.styleSheets.length; i++) {
const ss = document.styleSheets[i]; const ss = document.styleSheets[i];
if (!ss) continue; // well done safari >:( if (!ss) continue; // well done safari >:(
@ -100,18 +319,29 @@ function calcCssFixups() {
// Iterating through the CSS looking for matches to hack on feels // Iterating through the CSS looking for matches to hack on feels
// pretty horrible anyway. And what if the application skin doesn't use // pretty horrible anyway. And what if the application skin doesn't use
// Vector Green as its primary color? // Vector Green as its primary color?
// --richvdh
if (ss.href && !ss.href.match(/\/bundle.*\.css$/)) continue; // Yes, tinting assumes that you are using the Riot skin for now.
// The right solution will be to move the CSS over to react-sdk.
// And yes, the default assets for the base skin might as well use
// Vector Green as any other colour.
// --matthew
if (ss.href && !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
if (ss.disabled) continue;
if (!ss.cssRules) continue; if (!ss.cssRules) continue;
if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href);
for (let j = 0; j < ss.cssRules.length; j++) { for (let j = 0; j < ss.cssRules.length; j++) {
const rule = ss.cssRules[j]; const rule = ss.cssRules[j];
if (!rule.style) continue; if (!rule.style) continue;
for (let k = 0; k < cssAttrs.length; k++) { if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue;
const attr = cssAttrs[k]; for (let k = 0; k < this.cssAttrs.length; k++) {
for (let l = 0; l < keyRgb.length; l++) { const attr = this.cssAttrs[k];
if (rule.style[attr] === keyRgb[l]) { for (let l = 0; l < this.keyRgb.length; l++) {
cssFixups.push({ if (rule.style[attr] === this.keyRgb[l]) {
this.cssFixups[this.theme].push({
style: rule.style, style: rule.style,
attr: attr, attr: attr,
index: l, index: l,
@ -121,125 +351,37 @@ function calcCssFixups() {
} }
} }
} }
if (DEBUG) console.log("calcSvgFixups end"); if (DEBUG) {
} console.log("calcCssFixups end (" +
this.cssFixups[this.theme].length +
" fixups)");
}
}
function applyCssFixups() { applyCssFixups() {
if (DEBUG) console.log("applyCssFixups start"); if (DEBUG) {
for (let i = 0; i < cssFixups.length; i++) { console.log("applyCssFixups start (" +
const cssFixup = cssFixups[i]; this.cssFixups[this.theme].length +
cssFixup.style[cssFixup.attr] = colors[cssFixup.index]; " fixups)");
}
for (let i = 0; i < this.cssFixups[this.theme].length; i++) {
const cssFixup = this.cssFixups[this.theme][i];
try {
cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index];
} catch (e) {
// Firefox Quantum explodes if you manually edit the CSS in the
// inspector and then try to do a tint, as apparently all the
// fixups are then stale.
console.error("Failed to apply cssFixup in Tinter! ", e.name);
}
} }
if (DEBUG) console.log("applyCssFixups end"); if (DEBUG) console.log("applyCssFixups end");
}
function hexToRgb(color) {
if (color[0] === '#') color = color.slice(1);
if (color.length === 3) {
color = color[0] + color[0] +
color[1] + color[1] +
color[2] + color[2];
} }
const val = parseInt(color, 16);
const r = (val >> 16) & 255;
const g = (val >> 8) & 255;
const b = val & 255;
return [r, g, b];
}
function rgbToHex(rgb) {
const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
return '#' + (0x1000000 + val).toString(16).slice(1);
}
// List of functions to call when the tint changes.
const tintables = [];
module.exports = {
/**
* Register a callback to fire when the tint changes.
* This is used to rewrite the tintable SVGs with the new tint.
*
* It's not possible to unregister a tintable callback. So this can only be
* used to register a static callback. If a set of tintables will change
* over time then the best bet is to register a single callback for the
* entire set.
*
* @param {Function} tintable Function to call when the tint changes.
*/
registerTintable: function(tintable) {
tintables.push(tintable);
},
tint: function(primaryColor, secondaryColor, tertiaryColor) {
if (!cached) {
calcCssFixups();
cached = true;
}
if (!primaryColor) {
primaryColor = "#76CFA6"; // Vector green
secondaryColor = "#EAF5F0"; // Vector light green
}
if (!secondaryColor) {
const x = 0.16; // average weighting factor calculated from vector green & light green
const rgb = hexToRgb(primaryColor);
rgb[0] = x * rgb[0] + (1 - x) * 255;
rgb[1] = x * rgb[1] + (1 - x) * 255;
rgb[2] = x * rgb[2] + (1 - x) * 255;
secondaryColor = rgbToHex(rgb);
}
if (!tertiaryColor) {
const x = 0.19;
const rgb1 = hexToRgb(primaryColor);
const rgb2 = hexToRgb(secondaryColor);
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1];
rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2];
tertiaryColor = rgbToHex(rgb1);
}
if (colors[0] === primaryColor &&
colors[1] === secondaryColor &&
colors[2] === tertiaryColor) {
return;
}
colors[0] = primaryColor;
colors[1] = secondaryColor;
colors[2] = tertiaryColor;
if (DEBUG) console.log("Tinter.tint");
// go through manually fixing up the stylesheets.
applyCssFixups();
// tell all the SVGs to go fix themselves up
// we don't do this as a dispatch otherwise it will visually lag
tintables.forEach(function(tintable) {
tintable();
});
},
tintSvgWhite: function(whiteColor) {
if (!whiteColor) {
whiteColor = colors[3];
}
if (colors[3] === whiteColor) {
return;
}
colors[3] = whiteColor;
tintables.forEach(function(tintable) {
tintable();
});
},
// XXX: we could just move this all into TintableSvg, but as it's so similar // XXX: we could just move this all into TintableSvg, but as it's so similar
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
// keeping it here for now. // keeping it here for now.
calcSvgFixups: function(svgs) { calcSvgFixups(svgs) {
// go through manually fixing up SVG colours. // go through manually fixing up SVG colours.
// we could do this by stylesheets, but keeping the stylesheets // we could do this by stylesheets, but keeping the stylesheets
// updated would be a PITA, so just brute-force search for the // updated would be a PITA, so just brute-force search for the
@ -248,10 +390,10 @@ module.exports = {
if (DEBUG) console.log("calcSvgFixups start for " + svgs); if (DEBUG) console.log("calcSvgFixups start for " + svgs);
const fixups = []; const fixups = [];
for (let i = 0; i < svgs.length; i++) { for (let i = 0; i < svgs.length; i++) {
var svgDoc; let svgDoc;
try { try {
svgDoc = svgs[i].contentDocument; svgDoc = svgs[i].contentDocument;
} catch(e) { } catch (e) {
let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString(); let msg = 'Failed to get svg.contentDocument of ' + svgs[i].toString();
if (e.message) { if (e.message) {
msg += e.message; msg += e.message;
@ -259,16 +401,17 @@ module.exports = {
if (e.stack) { if (e.stack) {
msg += ' | stack: ' + e.stack; msg += ' | stack: ' + e.stack;
} }
console.error(e); console.error(msg);
} }
if (!svgDoc) continue; if (!svgDoc) continue;
const tags = svgDoc.getElementsByTagName("*"); const tags = svgDoc.getElementsByTagName("*");
for (let j = 0; j < tags.length; j++) { for (let j = 0; j < tags.length; j++) {
const tag = tags[j]; const tag = tags[j];
for (let k = 0; k < svgAttrs.length; k++) { for (let k = 0; k < this.svgAttrs.length; k++) {
const attr = svgAttrs[k]; const attr = this.svgAttrs[k];
for (let l = 0; l < keyHex.length; l++) { for (let l = 0; l < this.keyHex.length; l++) {
if (tag.getAttribute(attr) && tag.getAttribute(attr).toUpperCase() === keyHex[l]) { if (tag.getAttribute(attr) &&
tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) {
fixups.push({ fixups.push({
node: tag, node: tag,
attr: attr, attr: attr,
@ -282,14 +425,19 @@ module.exports = {
if (DEBUG) console.log("calcSvgFixups end"); if (DEBUG) console.log("calcSvgFixups end");
return fixups; return fixups;
}, }
applySvgFixups: function(fixups) { applySvgFixups(fixups) {
if (DEBUG) console.log("applySvgFixups start for " + fixups); if (DEBUG) console.log("applySvgFixups start for " + fixups);
for (let i = 0; i < fixups.length; i++) { for (let i = 0; i < fixups.length; i++) {
const svgFixup = fixups[i]; const svgFixup = fixups[i];
svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]); svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]);
} }
if (DEBUG) console.log("applySvgFixups end"); if (DEBUG) console.log("applySvgFixups end");
}, }
}; }
if (global.singletonTinter === undefined) {
global.singletonTinter = new Tinter();
}
export default global.singletonTinter;

View file

@ -1,51 +0,0 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from './dispatcher';
import sdk from './index';
import Modal from './Modal';
let isDialogOpen = false;
const onAction = function(payload) {
if (payload.action === 'unknown_device_error' && !isDialogOpen) {
const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog');
isDialogOpen = true;
Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, {
devices: payload.err.devices,
room: payload.room,
onFinished: (r) => {
isDialogOpen = false;
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log('UnknownDeviceDialog closed with '+r);
},
}, 'mx_Dialog_unknownDevice');
}
};
let ref = null;
export function startListening() {
ref = dis.register(onAction);
}
export function stopListening() {
if (ref) {
dis.unregister(ref);
ref = null;
}
}

View file

@ -15,7 +15,6 @@ limitations under the License.
*/ */
const MatrixClientPeg = require('./MatrixClientPeg'); const MatrixClientPeg = require('./MatrixClientPeg');
import UserSettingsStore from './UserSettingsStore';
import shouldHideEvent from './shouldHideEvent'; import shouldHideEvent from './shouldHideEvent';
const sdk = require('./index'); const sdk = require('./index');
@ -64,7 +63,6 @@ module.exports = {
// we have and the read receipt. We could fetch more history to try & find out, // we have and the read receipt. We could fetch more history to try & find out,
// but currently we just guess. // but currently we just guess.
const syncedSettings = UserSettingsStore.getSyncedSettings();
// Loop through messages, starting with the most recent... // Loop through messages, starting with the most recent...
for (let i = room.timeline.length - 1; i >= 0; --i) { for (let i = room.timeline.length - 1; i >= 0; --i) {
const ev = room.timeline[i]; const ev = room.timeline[i];
@ -74,7 +72,7 @@ module.exports = {
// that counts and we can stop looking because the user's read // that counts and we can stop looking because the user's read
// this and everything before. // this and everything before.
return false; return false;
} else if (!shouldHideEvent(ev, syncedSettings) && this.eventTriggersUnreadCount(ev)) { } else if (!shouldHideEvent(ev) && this.eventTriggersUnreadCount(ev)) {
// We've found a message that counts before we hit // We've found a message that counts before we hit
// the read marker, so this room is definitely unread. // the read marker, so this room is definitely unread.
return true; return true;

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 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.
@ -16,36 +17,11 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import Notifier from './Notifier';
import { _t } from './languageHandler';
/* /*
* TODO: Make things use this. This is all WIP - see UserSettings.js for usage. * TODO: Make things use this. This is all WIP - see UserSettings.js for usage.
*/ */
export default { export default {
LABS_FEATURES: [
{
name: "-",
id: 'matrix_apps',
default: true,
// XXX: Always use default, ignore localStorage and remove from labs
override: true,
},
{
name: "-",
id: 'feature_groups',
default: false,
},
],
// horrible but it works. The locality makes this somewhat more palatable.
doTranslations: function() {
this.LABS_FEATURES[0].name = _t("Matrix Apps");
this.LABS_FEATURES[1].name = _t("Groups");
},
loadProfileInfo: function() { loadProfileInfo: function() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
return cli.getProfileInfo(cli.credentials.userId); return cli.getProfileInfo(cli.credentials.userId);
@ -68,25 +44,6 @@ export default {
// TODO // TODO
}, },
getEnableNotifications: function() {
return Notifier.isEnabled();
},
setEnableNotifications: function(enable) {
if (!Notifier.supportsDesktopNotifications()) {
return;
}
Notifier.setEnabled(enable);
},
getEnableAudioNotifications: function() {
return Notifier.isAudioEnabled();
},
setEnableAudioNotifications: function(enable) {
Notifier.setAudioEnabled(enable);
},
changePassword: function(oldPassword, newPassword) { changePassword: function(oldPassword, newPassword) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -133,83 +90,4 @@ export default {
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
}); });
}, },
getUrlPreviewsDisabled: function() {
const event = MatrixClientPeg.get().getAccountData('org.matrix.preview_urls');
return (event && event.getContent().disable);
},
setUrlPreviewsDisabled: function(disabled) {
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData('org.matrix.preview_urls', {
disable: disabled,
});
},
getSyncedSettings: function() {
const event = MatrixClientPeg.get().getAccountData('im.vector.web.settings');
return event ? event.getContent() : {};
},
getSyncedSetting: function(type, defaultValue = null) {
const settings = this.getSyncedSettings();
return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
},
setSyncedSetting: function(type, value) {
const settings = this.getSyncedSettings();
settings[type] = value;
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData('im.vector.web.settings', settings);
},
getLocalSettings: function() {
const localSettingsString = localStorage.getItem('mx_local_settings') || '{}';
return JSON.parse(localSettingsString);
},
getLocalSetting: function(type, defaultValue = null) {
const settings = this.getLocalSettings();
return settings.hasOwnProperty(type) ? settings[type] : defaultValue;
},
setLocalSetting: function(type, value) {
const settings = this.getLocalSettings();
settings[type] = value;
// FIXME: handle errors
localStorage.setItem('mx_local_settings', JSON.stringify(settings));
},
getFeatureById(feature: string) {
for (let i = 0; i < this.LABS_FEATURES.length; i++) {
const f = this.LABS_FEATURES[i];
if (f.id === feature) {
return f;
}
}
return null;
},
isFeatureEnabled: function(featureId: string): boolean {
// Disable labs for guests.
if (MatrixClientPeg.get().isGuest()) return false;
const feature = this.getFeatureById(featureId);
if (!feature) {
console.warn(`Unknown feature "${featureId}"`);
return false;
}
// Return the default if this feature has an override to be the default value or
// if the feature has never been toggled and is therefore not in localStorage
if (Object.keys(feature).includes('override') ||
localStorage.getItem(`mx_labs_feature_${featureId}`) === null
) {
return feature.default;
}
return localStorage.getItem(`mx_labs_feature_${featureId}`) === 'true';
},
setFeatureEnabled: function(featureId: string, enabled: boolean) {
localStorage.setItem(`mx_labs_feature_${featureId}`, enabled);
},
}; };

View file

@ -68,9 +68,7 @@ module.exports = {
const names = whoIsTyping.map(function(m) { const names = whoIsTyping.map(function(m) {
return m.name; return m.name;
}); });
if (othersCount==1) { if (othersCount>=1) {
return _t('%(names)s and one other are typing', {names: names.slice(0, limit - 1).join(', ')});
} else if (othersCount>1) {
return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount}); return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount});
} else { } else {
const lastPerson = names.pop(); const lastPerson = names.pop();

326
src/WidgetMessaging.js Normal file
View file

@ -0,0 +1,326 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
Listens for incoming postMessage requests from embedded widgets. The following API is exposed:
{
api: "widget",
action: "content_loaded",
widgetId: $WIDGET_ID,
data: {}
// additional request fields
}
The complete request object is returned to the caller with an additional "response" key like so:
{
api: "widget",
action: "content_loaded",
widgetId: $WIDGET_ID,
data: {},
// additional request fields
response: { ... }
}
The "api" field is required to use this API, and must be set to "widget" in all requests.
The "action" determines the format of the request and response. All actions can return an error response.
Additional data can be sent as additional, abritrary fields. However, typically the data object should be used.
A success response is an object with zero or more keys.
An error response is a "response" object which consists of a sole "error" key to indicate an error.
They look like:
{
error: {
message: "Unable to invite user into room.",
_error: <Original Error Object>
}
}
The "message" key should be a human-friendly string.
ACTIONS
=======
** All actions must include an "api" field with valie "widget".**
All actions can return an error response instead of the response outlined below.
content_loaded
--------------
Indicates that widget contet has fully loaded
Request:
- widgetId is the unique ID of the widget instance in riot / matrix state.
- No additional fields.
Response:
{
success: true
}
Example:
{
api: "widget",
action: "content_loaded",
widgetId: $WIDGET_ID
}
api_version
-----------
Get the current version of the widget postMessage API
Request:
- No additional fields.
Response:
{
api_version: "0.0.1"
}
Example:
{
api: "widget",
action: "api_version",
}
supported_api_versions
----------------------
Get versions of the widget postMessage API that are currently supported
Request:
- No additional fields.
Response:
{
api: "widget"
supported_versions: ["0.0.1"]
}
Example:
{
api: "widget",
action: "supported_api_versions",
}
*/
import URL from 'url';
const WIDGET_API_VERSION = '0.0.1'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
'0.0.1',
];
import dis from './dispatcher';
if (!global.mxWidgetMessagingListenerCount) {
global.mxWidgetMessagingListenerCount = 0;
}
if (!global.mxWidgetMessagingMessageEndpoints) {
global.mxWidgetMessagingMessageEndpoints = [];
}
/**
* Register widget message event listeners
*/
function startListening() {
if (global.mxWidgetMessagingListenerCount === 0) {
window.addEventListener("message", onMessage, false);
}
global.mxWidgetMessagingListenerCount += 1;
}
/**
* De-register widget message event listeners
*/
function stopListening() {
global.mxWidgetMessagingListenerCount -= 1;
if (global.mxWidgetMessagingListenerCount === 0) {
window.removeEventListener("message", onMessage);
}
if (global.mxWidgetMessagingListenerCount < 0) {
// Make an error so we get a stack trace
const e = new Error(
"WidgetMessaging: mismatched startListening / stopListening detected." +
" Negative count",
);
console.error(e);
}
}
/**
* Register a widget endpoint for trusted postMessage communication
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
*/
function addEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn("Invalid origin:", endpointUrl);
return;
}
const origin = u.protocol + '//' + u.host;
const endpoint = new WidgetMessageEndpoint(widgetId, origin);
if (global.mxWidgetMessagingMessageEndpoints) {
if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) {
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
})) {
// Message endpoint already registered
console.warn("Endpoint already registered");
return;
}
global.mxWidgetMessagingMessageEndpoints.push(endpoint);
}
}
/**
* De-register a widget endpoint from trusted communication sources
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
* @return {boolean} True if endpoint was successfully removed
*/
function removeEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn("Invalid origin");
return;
}
const origin = u.protocol + '//' + u.host;
if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) {
const length = global.mxWidgetMessagingMessageEndpoints.length;
global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints.filter(function(endpoint) {
return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
});
return (length > global.mxWidgetMessagingMessageEndpoints.length);
}
return false;
}
/**
* Handle widget postMessage events
* @param {Event} event Event to handle
* @return {undefined}
*/
function onMessage(event) {
if (!event.origin) { // Handle chrome
event.origin = event.originalEvent.origin;
}
// Event origin is empty string if undefined
if (
event.origin.length === 0 ||
!trustedEndpoint(event.origin) ||
event.data.api !== "widget" ||
!event.data.widgetId
) {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
}
const action = event.data.action;
const widgetId = event.data.widgetId;
if (action === 'content_loaded') {
dis.dispatch({
action: 'widget_content_loaded',
widgetId: widgetId,
});
sendResponse(event, {success: true});
} else if (action === 'supported_api_versions') {
sendResponse(event, {
api: "widget",
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
});
} else if (action === 'api_version') {
sendResponse(event, {
api: "widget",
version: WIDGET_API_VERSION,
});
} else {
console.warn("Widget postMessage event unhandled");
sendError(event, {message: "The postMessage was unhandled"});
}
}
/**
* Check if message origin is registered as trusted
* @param {string} origin PostMessage origin to check
* @return {boolean} True if trusted
*/
function trustedEndpoint(origin) {
if (!origin) {
return false;
}
return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => {
return endpoint.endpointUrl === origin;
});
}
/**
* Send a postmessage response to a postMessage request
* @param {Event} event The original postMessage request event
* @param {Object} res Response data
*/
function sendResponse(event, res) {
const data = JSON.parse(JSON.stringify(event.data));
data.response = res;
event.source.postMessage(data, event.origin);
}
/**
* Send an error response to a postMessage request
* @param {Event} event The original postMessage request event
* @param {string} msg Error message
* @param {Error} nestedError Nested error event (optional)
*/
function sendError(event, msg, nestedError) {
console.error("Action:" + event.data.action + " failed with message: " + msg);
const data = JSON.parse(JSON.stringify(event.data));
data.response = {
error: {
message: msg,
},
};
if (nestedError) {
data.response.error._error = nestedError;
}
event.source.postMessage(data, event.origin);
}
/**
* Represents mapping of widget instance to URLs for trusted postMessage communication.
*/
class WidgetMessageEndpoint {
/**
* Mapping of widget instance to URL for trusted postMessage communication.
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin.
*/
constructor(widgetId, endpointUrl) {
if (!widgetId) {
throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
}
if (!endpointUrl) {
throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
}
this.widgetId = widgetId;
this.endpointUrl = endpointUrl;
}
}
export default {
startListening: startListening,
stopListening: stopListening,
addEndpoint: addEndpoint,
removeEndpoint: removeEndpoint,
};

View file

@ -0,0 +1,34 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { asyncAction } from './actionCreators';
const GroupActions = {};
/**
* Creates an action thunk that will do an asynchronous request to fetch
* the groups to which a user is joined.
*
* @param {MatrixClient} matrixClient the matrix client to query.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
GroupActions.fetchJoinedGroups = function(matrixClient) {
return asyncAction('GroupActions.fetchJoinedGroups', () => matrixClient.getJoinedGroups());
};
export default GroupActions;

View file

@ -0,0 +1,108 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import dis from '../dispatcher';
// TODO: migrate from sync_state to MatrixActions.sync so that more js-sdk events
// become dispatches in the same place.
/**
* Create a MatrixActions.sync action that represents a MatrixClient `sync` event,
* each parameter mapping to a key-value in the action.
*
* @param {MatrixClient} matrixClient the matrix client
* @param {string} state the current sync state.
* @param {string} prevState the previous sync state.
* @returns {Object} an action of type MatrixActions.sync.
*/
function createSyncAction(matrixClient, state, prevState) {
return {
action: 'MatrixActions.sync',
state,
prevState,
matrixClient,
};
}
/**
* @typedef AccountDataAction
* @type {Object}
* @property {string} action 'MatrixActions.accountData'.
* @property {MatrixEvent} event the MatrixEvent that triggered the dispatch.
* @property {string} event_type the type of the MatrixEvent, e.g. "m.direct".
* @property {Object} event_content the content of the MatrixEvent.
*/
/**
* Create a MatrixActions.accountData action that represents a MatrixClient `accountData`
* matrix event.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} accountDataEvent the account data event.
* @returns {AccountDataAction} an action of type MatrixActions.accountData.
*/
function createAccountDataAction(matrixClient, accountDataEvent) {
return {
action: 'MatrixActions.accountData',
event: accountDataEvent,
event_type: accountDataEvent.getType(),
event_content: accountDataEvent.getContent(),
};
}
/**
* This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient.
*/
export default {
// A list of callbacks to call to unregister all listeners added
_matrixClientListenersStop: [],
/**
* Start listening to certain events from the MatrixClient and dispatch actions when
* they are emitted.
* @param {MatrixClient} matrixClient the MatrixClient to listen to events from
*/
start(matrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
},
/**
* Start listening to events of type eventName on matrixClient and when they are emitted,
* dispatch an action created by the actionCreator function.
* @param {MatrixClient} matrixClient a MatrixClient to register a listener with.
* @param {string} eventName the event to listen to on MatrixClient.
* @param {function} actionCreator a function that should return an action to dispatch
* when given the MatrixClient as an argument as well as
* arguments emitted in the MatrixClient event.
*/
_addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => {
dis.dispatch(actionCreator(matrixClient, ...args));
};
matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => {
matrixClient.removeListener(eventName, listener);
});
},
/**
* Stop listening to events.
*/
stop() {
this._matrixClientListenersStop.forEach((stopListener) => stopListener());
},
};

View file

@ -0,0 +1,47 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Analytics from '../Analytics';
import { asyncAction } from './actionCreators';
import TagOrderStore from '../stores/TagOrderStore';
const TagOrderActions = {};
/**
* Creates an action thunk that will do an asynchronous request to
* commit TagOrderStore.getOrderedTags() to account data and dispatch
* actions to indicate the status of the request.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
TagOrderActions.commitTagOrdering = function(matrixClient) {
return asyncAction('TagOrderActions.commitTagOrdering', () => {
// Only commit tags if the state is ready, i.e. not null
const tags = TagOrderStore.getOrderedTags();
if (!tags) {
return;
}
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags});
});
};
export default TagOrderActions;

View file

@ -0,0 +1,41 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Create an action thunk that will dispatch actions indicating the current
* status of the Promise returned by fn.
*
* @param {string} id the id to give the dispatched actions. This is given a
* suffix determining whether it is pending, successful or
* a failure.
* @param {function} fn a function that returns a Promise.
* @returns {function} an action thunk - a function that uses its single
* argument as a dispatch function to dispatch the
* following actions:
* `${id}.pending` and either
* `${id}.success` or
* `${id}.failure`.
*/
export function asyncAction(id, fn) {
return (dispatch) => {
dispatch({action: id + '.pending'});
fn().then((result) => {
dispatch({action: id + '.success', result});
}).catch((err) => {
dispatch({action: id + '.failure', err});
});
};
}

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -28,6 +29,10 @@ export default class AutocompleteProvider {
} }
} }
destroy() {
// stub
}
/** /**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/ */

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 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,7 @@ import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider'; import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider'; import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider'; import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider';
import Promise from 'bluebird'; import Promise from 'bluebird';
export type SelectionRange = { export type SelectionRange = {
@ -43,15 +45,30 @@ const PROVIDERS = [
UserProvider, UserProvider,
RoomProvider, RoomProvider,
EmojiProvider, EmojiProvider,
NotifProvider,
CommandProvider, CommandProvider,
DuckDuckGoProvider, DuckDuckGoProvider,
].map((completer) => completer.getInstance()); ];
// Providers will get rejected if they take longer than this. // Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000; const PROVIDER_COMPLETION_TIMEOUT = 3000;
export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> { export default class Autocompleter {
/* Note: That this waits for all providers to return is *intentional* constructor(room) {
this.room = room;
this.providers = PROVIDERS.map((p) => {
return new p(room);
});
}
destroy() {
this.providers.forEach((p) => {
p.destroy();
});
}
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
/* Note: This intentionally waits for all providers to return,
otherwise, we run into a condition where new completions are displayed otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended to predict whether an action will actually do what is intended
@ -60,7 +77,7 @@ export async function getCompletions(query: string, selection: SelectionRange, f
// 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
PROVIDERS.map((provider) => { this.providers.map((provider) => {
return provider return provider
.getCompletions(query, selection, force) .getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT) .timeout(PROVIDER_COMPLETION_TIMEOUT)
@ -73,13 +90,14 @@ export async function getCompletions(query: string, selection: SelectionRange, f
).map((completionsState, i) => { ).map((completionsState, i) => {
return { return {
completions: completionsState.value(), completions: completionsState.value(),
provider: PROVIDERS[i], provider: this.providers[i],
/* the currently matched "command" the completer tried to complete /* the currently matched "command" the completer tried to complete
* we pass this through so that Autocomplete can figure out when to * we pass this through so that Autocomplete can figure out when to
* re-show itself once hidden. * re-show itself once hidden.
*/ */
command: PROVIDERS[i].getCurrentCommand(query, selection, force), command: this.providers[i].getCurrentCommand(query, selection, force),
}; };
}); });
}
} }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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.
@ -109,8 +110,6 @@ const COMMANDS = [
const COMMAND_RE = /(^\/\w*)/g; const COMMAND_RE = /(^\/\w*)/g;
let instance = null;
export default class CommandProvider extends AutocompleteProvider { export default class CommandProvider extends AutocompleteProvider {
constructor() { constructor() {
super(COMMAND_RE); super(COMMAND_RE);
@ -142,12 +141,6 @@ export default class CommandProvider extends AutocompleteProvider {
return '*️⃣ ' + _t('Commands'); return '*️⃣ ' + _t('Commands');
} }
static getInstance(): CommandProvider {
if (instance === null) instance = new CommandProvider();
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block"> return <div className="mx_Autocomplete_Completion_container_block">
{ completions } { completions }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -25,8 +26,6 @@ import {TextualCompletion} from './Components';
const DDG_REGEX = /\/ddg\s+(.+)$/g; const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector'; const REFERRER = 'vector';
let instance = null;
export default class DuckDuckGoProvider extends AutocompleteProvider { export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() { constructor() {
super(DDG_REGEX); super(DDG_REGEX);
@ -96,13 +95,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
return '🔍 ' + _t('Results from DuckDuckGo'); return '🔍 ' + _t('Results from DuckDuckGo');
} }
static getInstance(): DuckDuckGoProvider {
if (instance == null) {
instance = new DuckDuckGoProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_block"> return <div className="mx_Autocomplete_Completion_container_block">
{ completions } { completions }

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -25,7 +26,7 @@ import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter'; import type {SelectionRange, Completion} from './Autocompleter';
import _uniq from 'lodash/uniq'; import _uniq from 'lodash/uniq';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import UserSettingsStore from '../UserSettingsStore'; import SettingsStore from "../settings/SettingsStore";
import EmojiData from '../stripped-emoji.json'; import EmojiData from '../stripped-emoji.json';
@ -70,8 +71,6 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor
}; };
}); });
let instance = null;
function score(query, space) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
if (index === -1) { if (index === -1) {
@ -97,7 +96,7 @@ export default class EmojiProvider extends AutocompleteProvider {
} }
async getCompletions(query: string, selection: SelectionRange) { async getCompletions(query: string, selection: SelectionRange) {
if (UserSettingsStore.getSyncedSetting("MessageComposerInput.dontSuggestEmoji")) { if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) {
return []; // don't give any suggestions if the user doesn't want them return []; // don't give any suggestions if the user doesn't want them
} }
@ -151,11 +150,6 @@ export default class EmojiProvider extends AutocompleteProvider {
return '😃 ' + _t('Emoji'); return '😃 ' + _t('Emoji');
} }
static getInstance() {
if (instance == null) {instance = new EmojiProvider();}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return <div className="mx_Autocomplete_Completion_container_pill">
{ completions } { completions }

View file

@ -0,0 +1,62 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import { _t } from '../languageHandler';
import MatrixClientPeg from '../MatrixClientPeg';
import {PillCompletion} from './Components';
import sdk from '../index';
const AT_ROOM_REGEX = /@\S*/g;
export default class NotifProvider extends AutocompleteProvider {
constructor(room) {
super(AT_ROOM_REGEX);
this.room = room;
}
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get();
if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return [];
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) {
return [{
completion: '@room',
suffix: ' ',
component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />
),
range,
}];
}
return [];
}
getName() {
return '❗️ ' + _t('Room Notification');
}
renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions }
</div>;
}
}

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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,8 +28,6 @@ import _sortBy from 'lodash/sortBy';
const ROOM_REGEX = /(?=#)(\S*)/g; const ROOM_REGEX = /(?=#)(\S*)/g;
let instance = null;
function score(query, space) { function score(query, space) {
const index = space.indexOf(query); const index = space.indexOf(query);
if (index === -1) { if (index === -1) {
@ -96,14 +95,6 @@ export default class RoomProvider extends AutocompleteProvider {
return '💬 ' + _t('Rooms'); return '💬 ' + _t('Rooms');
} }
static getInstance() {
if (instance == null) {
instance = new RoomProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions } { completions }

View file

@ -2,6 +2,7 @@
/* /*
Copyright 2016 Aviral Dasgupta Copyright 2016 Aviral Dasgupta
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
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,20 +31,57 @@ import type {Room, RoomMember} from 'matrix-js-sdk';
const USER_REGEX = /@\S*/g; const USER_REGEX = /@\S*/g;
let instance = null;
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() { constructor(room) {
super(USER_REGEX, { super(USER_REGEX, {
keys: ['name'], keys: ['name'],
}); });
this.room = room;
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
shouldMatchPrefix: true, shouldMatchPrefix: true,
}); });
this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
this._onRoomStateMemberBound = this._onRoomStateMember.bind(this);
MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound);
}
destroy() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound);
MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound);
}
}
_onRoomTimeline(ev, room, toStartOfTimeline, removed, data) {
if (!room) return;
if (removed) return;
if (room.roomId !== this.room.roomId) return;
// ignore events from filtered timelines
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
// ignore anything but real-time updates at the end of the room:
// updates from pagination will happen when the paginate completes.
if (toStartOfTimeline || !data || !data.liveEvent) return;
this.onUserSpoke(ev.sender);
}
_onRoomStateMember(ev, state, member) {
// ignore members in other rooms
if (member.roomId !== this.room.roomId) {
return;
}
// blow away the users cache
this.users = null;
} }
async getCompletions(query: string, selection: {start: number, end: number}, force = false) { async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
@ -86,16 +124,11 @@ export default class UserProvider extends AutocompleteProvider {
return '👥 ' + _t('Users'); return '👥 ' + _t('Users');
} }
setUserListFromRoom(room: Room) {
this.room = room;
this.users = null;
}
_makeUsers() { _makeUsers() {
const events = this.room.getLiveTimeline().getEvents(); const events = this.room.getLiveTimeline().getEvents();
const lastSpoken = {}; const lastSpoken = {};
for(const event of events) { for (const event of events) {
lastSpoken[event.getSender()] = event.getTs(); lastSpoken[event.getSender()] = event.getTs();
} }
@ -123,13 +156,6 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher.setObjects(this.users); this.matcher.setObjects(this.users);
} }
static getInstance(): UserProvider {
if (instance == null) {
instance = new UserProvider();
}
return instance;
}
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate">
{ completions } { completions }

View file

@ -33,6 +33,7 @@ module.exports = {
menuHeight: React.PropTypes.number, menuHeight: React.PropTypes.number,
chevronOffset: React.PropTypes.number, chevronOffset: React.PropTypes.number,
menuColour: React.PropTypes.string, menuColour: React.PropTypes.string,
chevronFace: React.PropTypes.string, // top, bottom, left, right
}, },
getOrCreateContainer: function() { getOrCreateContainer: function() {
@ -58,12 +59,30 @@ module.exports = {
} }
}; };
const position = { const position = {};
top: props.top, let chevronFace = null;
};
if (props.top) {
position.top = props.top;
} else {
position.bottom = props.bottom;
}
if (props.left) {
position.left = props.left;
chevronFace = 'left';
} else {
position.right = props.right;
chevronFace = 'right';
}
const chevronOffset = {}; const chevronOffset = {};
if (props.chevronOffset) { if (props.chevronFace) {
chevronFace = props.chevronFace;
}
if (chevronFace === 'top' || chevronFace === 'bottom') {
chevronOffset.left = props.chevronOffset;
} else {
chevronOffset.top = props.chevronOffset; chevronOffset.top = props.chevronOffset;
} }
@ -74,28 +93,27 @@ module.exports = {
.mx_ContextualMenu_chevron_left:after { .mx_ContextualMenu_chevron_left:after {
border-right-color: ${props.menuColour}; border-right-color: ${props.menuColour};
} }
.mx_ContextualMenu_chevron_right:after { .mx_ContextualMenu_chevron_right:after {
border-left-color: ${props.menuColour}; border-left-color: ${props.menuColour};
} }
.mx_ContextualMenu_chevron_top:after {
border-left-color: ${props.menuColour};
}
.mx_ContextualMenu_chevron_bottom:after {
border-left-color: ${props.menuColour};
}
`; `;
} }
let chevron = null; const chevron = <div style={chevronOffset} className={"mx_ContextualMenu_chevron_" + chevronFace}></div>;
if (props.left) {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_left"></div>;
position.left = props.left;
} else {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_right"></div>;
position.right = props.right;
}
const className = 'mx_ContextualMenu_wrapper'; const className = 'mx_ContextualMenu_wrapper';
const menuClasses = classNames({ const menuClasses = classNames({
'mx_ContextualMenu': true, 'mx_ContextualMenu': true,
'mx_ContextualMenu_left': props.left, 'mx_ContextualMenu_left': chevronFace === 'left',
'mx_ContextualMenu_right': !props.left, 'mx_ContextualMenu_right': chevronFace === 'right',
'mx_ContextualMenu_top': chevronFace === 'top',
'mx_ContextualMenu_bottom': chevronFace === 'bottom',
}); });
const menuStyle = {}; const menuStyle = {};

View file

@ -19,7 +19,7 @@ import React from 'react';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import sdk from '../../index'; import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
import { _t, _tJsx } from '../../languageHandler'; import { _t } from '../../languageHandler';
/* /*
* Component which shows the filtered file using a TimelinePanel * Component which shows the filtered file using a TimelinePanel
@ -92,7 +92,10 @@ const FilePanel = React.createClass({
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
return <div className="mx_FilePanel mx_RoomView_messageListWrapper"> return <div className="mx_FilePanel mx_RoomView_messageListWrapper">
<div className="mx_RoomView_empty"> <div className="mx_RoomView_empty">
{ _tJsx("You must <a>register</a> to use this functionality", /<a>(.*?)<\/a>/, (sub) => <a href="#/register" key="sub">{ sub }</a>) } { _t("You must <a>register</a> to use this functionality",
{},
{ 'a': (sub) => <a href="#/register" key="sub">{ sub }</a> })
}
</div> </div>
</div>; </div>;
} else if (this.noRoom) { } else if (this.noRoom) {
@ -116,7 +119,6 @@ const FilePanel = React.createClass({
timelineSet={this.state.timelineSet} timelineSet={this.state.timelineSet}
showUrlPreview = {false} showUrlPreview = {false}
tileShape="file_grid" tileShape="file_grid"
opacity={this.props.opacity}
empty={_t('There are no visible files in this room')} empty={_t('There are no visible files in this room')}
/> />
); );

View file

@ -22,13 +22,26 @@ import MatrixClientPeg from '../../MatrixClientPeg';
import sdk from '../../index'; import sdk from '../../index';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import { sanitizedHtmlNode } from '../../HtmlUtils'; import { sanitizedHtmlNode } from '../../HtmlUtils';
import { _t } from '../../languageHandler'; import { _t, _td } from '../../languageHandler';
import AccessibleButton from '../views/elements/AccessibleButton'; import AccessibleButton from '../views/elements/AccessibleButton';
import Modal from '../../Modal'; import Modal from '../../Modal';
import classnames from 'classnames'; import classnames from 'classnames';
import GroupStoreCache from '../../stores/GroupStoreCache'; import GroupStoreCache from '../../stores/GroupStoreCache';
import GroupStore from '../../stores/GroupStore'; import GroupStore from '../../stores/GroupStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GeminiScrollbar from 'react-gemini-scrollbar';
const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1>
<p>
Use the long description to introduce new members to the community, or distribute
some important <a href="foo">links</a>
</p>
<p>
You can even use 'img' tags
</p>
`);
const RoomSummaryType = PropTypes.shape({ const RoomSummaryType = PropTypes.shape({
room_id: PropTypes.string.isRequired, room_id: PropTypes.string.isRequired,
@ -64,11 +77,11 @@ const CategoryRoomList = React.createClass({
editing: PropTypes.bool.isRequired, editing: PropTypes.bool.isRequired,
}, },
onAddRoomsClicked: function(ev) { onAddRoomsToSummaryClicked: function(ev) {
ev.preventDefault(); ev.preventDefault();
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, { Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, {
title: _t('Add rooms to the group summary'), title: _t('Add rooms to the community summary'),
description: _t("Which rooms would you like to add to this summary?"), description: _t("Which rooms would you like to add to this summary?"),
placeholder: _t("Room name or alias"), placeholder: _t("Room name or alias"),
button: _t("Add to summary"), button: _t("Add to summary"),
@ -106,7 +119,9 @@ const CategoryRoomList = React.createClass({
render: function() { render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const addButton = this.props.editing ? const addButton = this.props.editing ?
(<AccessibleButton className="mx_GroupView_featuredThings_addButton" onClick={this.onAddRoomsClicked}> (<AccessibleButton className="mx_GroupView_featuredThings_addButton"
onClick={this.onAddRoomsToSummaryClicked}
>
<TintableSvg src="img/icons-create-room.svg" width="64" height="64" /> <TintableSvg src="img/icons-create-room.svg" width="64" height="64" />
<div className="mx_GroupView_featuredThings_addButton_label"> <div className="mx_GroupView_featuredThings_addButton_label">
{ _t('Add a Room') } { _t('Add a Room') }
@ -242,7 +257,7 @@ const RoleUserList = React.createClass({
ev.preventDefault(); ev.preventDefault();
const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog");
Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, { Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, {
title: _t('Add users to the group summary'), title: _t('Add users to the community summary'),
description: _t("Who would you like to add to this summary?"), description: _t("Who would you like to add to this summary?"),
placeholder: _t("Name or matrix ID"), placeholder: _t("Name or matrix ID"),
button: _t("Add to summary"), button: _t("Add to summary"),
@ -263,7 +278,7 @@ const RoleUserList = React.createClass({
} }
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Failed to add the following users to the group summary', 'Failed to add the following users to the community summary',
'', ErrorDialog, '', ErrorDialog,
{ {
title: _t( title: _t(
@ -335,7 +350,7 @@ const FeaturedUser = React.createClass({
const displayName = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id; const displayName = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id;
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Failed to remove user from group summary', 'Failed to remove user from community summary',
'', ErrorDialog, '', ErrorDialog,
{ {
title: _t( title: _t(
@ -388,6 +403,8 @@ export default React.createClass({
propTypes: { propTypes: {
groupId: PropTypes.string.isRequired, groupId: PropTypes.string.isRequired,
// Whether this is the first time the group admin is viewing the group
groupIsNew: PropTypes.bool,
}, },
childContextTypes: { childContextTypes: {
@ -403,24 +420,30 @@ export default React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
summary: null, summary: null,
isGroupPublicised: null,
isUserPrivileged: null,
groupRooms: null,
groupRoomsLoading: null,
error: null, error: null,
editing: false, editing: false,
saving: false, saving: false,
uploadingAvatar: false, uploadingAvatar: false,
membershipBusy: false, membershipBusy: false,
publicityBusy: false, publicityBusy: false,
inviterProfile: null,
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._changeAvatarComponent = null; this._matrixClient = MatrixClientPeg.get();
this._initGroupStore(this.props.groupId); this._matrixClient.on("Group.myMembership", this._onGroupMyMembership);
MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership); this._changeAvatarComponent = null;
this._initGroupStore(this.props.groupId, true);
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership); this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
this._groupStore.removeAllListeners(); this._groupStore.removeAllListeners();
}, },
@ -441,15 +464,50 @@ export default React.createClass({
this.setState({membershipBusy: false}); this.setState({membershipBusy: false});
}, },
_initGroupStore: function(groupId) { _initGroupStore: function(groupId, firstInit) {
this._groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); const group = this._matrixClient.getGroup(groupId);
this._groupStore.on('update', () => { if (group && group.inviter && group.inviter.userId) {
this._fetchInviterProfile(group.inviter.userId);
}
this._groupStore = GroupStoreCache.getGroupStore(groupId);
this._groupStore.registerListener(() => {
const summary = this._groupStore.getSummary();
if (summary.profile) {
// Default profile fields should be "" for later sending to the server (which
// requires that the fields are strings, not null)
["avatar_url", "long_description", "name", "short_description"].forEach((k) => {
summary.profile[k] = summary.profile[k] || "";
});
}
this.setState({ this.setState({
summary: this._groupStore.getSummary(), summary,
summaryLoading: !this._groupStore.isStateReady(GroupStore.STATE_KEY.Summary),
isGroupPublicised: this._groupStore.getGroupPublicity(),
isUserPrivileged: this._groupStore.isUserPrivileged(),
groupRooms: this._groupStore.getGroupRooms(),
groupRoomsLoading: !this._groupStore.isStateReady(GroupStore.STATE_KEY.GroupRooms),
isUserMember: this._groupStore.getGroupMembers().some(
(m) => m.userId === this._matrixClient.credentials.userId,
),
error: null, error: null,
}); });
if (this.props.groupIsNew && firstInit) {
this._onEditClick();
}
}); });
let willDoOnboarding = false;
this._groupStore.on('error', (err) => { this._groupStore.on('error', (err) => {
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN' && !willDoOnboarding) {
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_group',
group_id: groupId,
},
});
dis.dispatch({action: 'view_set_mxid'});
willDoOnboarding = true;
}
this.setState({ this.setState({
summary: null, summary: null,
error: err, error: err,
@ -457,6 +515,26 @@ export default React.createClass({
}); });
}, },
_fetchInviterProfile(userId) {
this.setState({
inviterProfileBusy: true,
});
this._matrixClient.getProfileInfo(userId).then((resp) => {
this.setState({
inviterProfile: {
avatarUrl: resp.avatar_url,
displayName: resp.displayname,
},
});
}).catch((e) => {
console.error('Error getting group inviter profile', e);
}).finally(() => {
this.setState({
inviterProfileBusy: false,
});
});
},
_onShowRhsClick: function(ev) { _onShowRhsClick: function(ev) {
dis.dispatch({ action: 'show_right_panel' }); dis.dispatch({ action: 'show_right_panel' });
}, },
@ -466,6 +544,10 @@ export default React.createClass({
editing: true, editing: true,
profileForm: Object.assign({}, this.state.summary.profile), profileForm: Object.assign({}, this.state.summary.profile),
}); });
dis.dispatch({
action: 'panel_disable',
sideDisabled: true,
});
}, },
_onCancelClick: function() { _onCancelClick: function() {
@ -473,17 +555,18 @@ export default React.createClass({
editing: false, editing: false,
profileForm: null, profileForm: null,
}); });
dis.dispatch({action: 'panel_disable'});
}, },
_onNameChange: function(e) { _onNameChange: function(value) {
const newProfileForm = Object.assign(this.state.profileForm, { name: e.target.value }); const newProfileForm = Object.assign(this.state.profileForm, { name: value });
this.setState({ this.setState({
profileForm: newProfileForm, profileForm: newProfileForm,
}); });
}, },
_onShortDescChange: function(e) { _onShortDescChange: function(value) {
const newProfileForm = Object.assign(this.state.profileForm, { short_description: e.target.value }); const newProfileForm = Object.assign(this.state.profileForm, { short_description: value });
this.setState({ this.setState({
profileForm: newProfileForm, profileForm: newProfileForm,
}); });
@ -501,7 +584,7 @@ export default React.createClass({
if (!file) return; if (!file) return;
this.setState({uploadingAvatar: true}); this.setState({uploadingAvatar: true});
MatrixClientPeg.get().uploadContent(file).then((url) => { this._matrixClient.uploadContent(file).then((url) => {
const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url }); const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url });
this.setState({ this.setState({
uploadingAvatar: false, uploadingAvatar: false,
@ -520,29 +603,33 @@ export default React.createClass({
_onSaveClick: function() { _onSaveClick: function() {
this.setState({saving: true}); this.setState({saving: true});
MatrixClientPeg.get().setGroupProfile(this.props.groupId, this.state.profileForm).then((result) => { const savePromise = this.state.isUserPrivileged ?
this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm) :
Promise.resolve();
savePromise.then((result) => {
this.setState({ this.setState({
saving: false, saving: false,
editing: false, editing: false,
summary: null, summary: null,
}); });
dis.dispatch({action: 'panel_disable'});
this._initGroupStore(this.props.groupId); this._initGroupStore(this.props.groupId);
}).catch((e) => { }).catch((e) => {
this.setState({ this.setState({
saving: false, saving: false,
}); });
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to save group profile", e); console.error("Failed to save community profile", e);
Modal.createTrackedDialog('Failed to update group', '', ErrorDialog, { Modal.createTrackedDialog('Failed to update group', '', ErrorDialog, {
title: _t('Error'), title: _t('Error'),
description: _t('Failed to update group'), description: _t('Failed to update community'),
}); });
}).done(); }).done();
}, },
_onAcceptInviteClick: function() { _onAcceptInviteClick: function() {
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
MatrixClientPeg.get().acceptGroupInvite(this.props.groupId).then(() => { this._groupStore.acceptGroupInvite().then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync // don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => { }).catch((e) => {
this.setState({membershipBusy: false}); this.setState({membershipBusy: false});
@ -556,7 +643,7 @@ export default React.createClass({
_onRejectInviteClick: function() { _onRejectInviteClick: function() {
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
MatrixClientPeg.get().leaveGroup(this.props.groupId).then(() => { this._matrixClient.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync // don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => { }).catch((e) => {
this.setState({membershipBusy: false}); this.setState({membershipBusy: false});
@ -571,7 +658,7 @@ export default React.createClass({
_onLeaveClick: function() { _onLeaveClick: function() {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Leave Group', '', QuestionDialog, { Modal.createTrackedDialog('Leave Group', '', QuestionDialog, {
title: _t("Leave Group"), title: _t("Leave Community"),
description: _t("Leave %(groupName)s?", {groupName: this.props.groupId}), description: _t("Leave %(groupName)s?", {groupName: this.props.groupId}),
button: _t("Leave"), button: _t("Leave"),
danger: true, danger: true,
@ -579,7 +666,7 @@ export default React.createClass({
if (!confirmed) return; if (!confirmed) return;
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
MatrixClientPeg.get().leaveGroup(this.props.groupId).then(() => { this._matrixClient.leaveGroup(this.props.groupId).then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync // don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => { }).catch((e) => {
this.setState({membershipBusy: false}); this.setState({membershipBusy: false});
@ -593,23 +680,68 @@ export default React.createClass({
}); });
}, },
_onPubliciseOffClick: function() { _onAddRoomsClick: function() {
this._setPublicity(false); showGroupAddRoomDialog(this.props.groupId);
}, },
_onPubliciseOnClick: function() { _getGroupSection: function() {
this._setPublicity(true); const groupSettingsSectionClasses = classnames({
"mx_GroupView_group": this.state.editing,
"mx_GroupView_group_disabled": this.state.editing && !this.state.isUserPrivileged,
});
const header = this.state.editing ? <h2> { _t('Community Settings') } </h2> : <div />;
return <div className={groupSettingsSectionClasses}>
{ header }
{ this._getLongDescriptionNode() }
{ this._getRoomsNode() }
</div>;
}, },
_setPublicity: function(publicity) { _getRoomsNode: function() {
this.setState({ const RoomDetailList = sdk.getComponent('rooms.RoomDetailList');
publicityBusy: true, const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
}); const TintableSvg = sdk.getComponent('elements.TintableSvg');
this._groupStore.setGroupPublicity(publicity).then(() => { const Spinner = sdk.getComponent('elements.Spinner');
this.setState({ const ToolTipButton = sdk.getComponent('elements.ToolTipButton');
publicityBusy: false,
}); const roomsHelpNode = this.state.editing ? <ToolTipButton helpText={
_t(
'These rooms are displayed to community members on the community page. '+
'Community members can join the rooms by clicking on them.',
)
} /> : <div />;
const addRoomRow = this.state.editing ?
(<AccessibleButton className="mx_GroupView_rooms_header_addRow"
onClick={this._onAddRoomsClick}
>
<div className="mx_GroupView_rooms_header_addRow_button">
<TintableSvg src="img/icons-room-add.svg" width="24" height="24" />
</div>
<div className="mx_GroupView_rooms_header_addRow_label">
{ _t('Add rooms to this community') }
</div>
</AccessibleButton>) : <div />;
const roomDetailListClassName = classnames({
"mx_fadable": true,
"mx_fadable_faded": this.state.editing,
}); });
return <div className="mx_GroupView_rooms">
<div className="mx_GroupView_rooms_header">
<h3>
{ _t('Rooms') }
{ roomsHelpNode }
</h3>
{ addRoomRow }
</div>
{ this.state.groupRoomsLoading ?
<Spinner /> :
<RoomDetailList
rooms={this.state.groupRooms}
className={roomDetailListClassName} />
}
</div>;
}, },
_getFeaturedRoomsNode: function() { _getFeaturedRoomsNode: function() {
@ -696,20 +828,37 @@ export default React.createClass({
_getMembershipSection: function() { _getMembershipSection: function() {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const group = MatrixClientPeg.get().getGroup(this.props.groupId); const group = this._matrixClient.getGroup(this.props.groupId);
if (!group) return null; if (!group) return null;
if (group.myMembership === 'invite') { if (group.myMembership === 'invite') {
if (this.state.membershipBusy) { if (this.state.membershipBusy || this.state.inviterProfileBusy) {
return <div className="mx_GroupView_membershipSection"> return <div className="mx_GroupView_membershipSection">
<Spinner /> <Spinner />
</div>; </div>;
} }
const httpInviterAvatar = this.state.inviterProfile ?
this._matrixClient.mxcUrlToHttp(
this.state.inviterProfile.avatarUrl, 36, 36,
) : null;
let inviterName = group.inviter.userId;
if (this.state.inviterProfile) {
inviterName = this.state.inviterProfile.displayName || group.inviter.userId;
}
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_invited"> return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_invited">
<div className="mx_GroupView_membershipSubSection">
<div className="mx_GroupView_membershipSection_description"> <div className="mx_GroupView_membershipSection_description">
{ _t("%(inviter)s has invited you to join this group", {inviter: group.inviter.userId}) } <BaseAvatar url={httpInviterAvatar}
name={inviterName}
width={36}
height={36}
/>
{ _t("%(inviter)s has invited you to join this community", {
inviter: inviterName,
}) }
</div> </div>
<div className="mx_GroupView_membership_buttonContainer"> <div className="mx_GroupView_membership_buttonContainer">
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton" <AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
@ -723,94 +872,102 @@ export default React.createClass({
{ _t("Decline") } { _t("Decline") }
</AccessibleButton> </AccessibleButton>
</div> </div>
</div>;
} else if (group.myMembership === 'join') {
let youAreAMemberText = _t("You are a member of this group");
if (this.state.summary.user && this.state.summary.user.is_privileged) {
youAreAMemberText = _t("You are an administrator of this group");
}
let publicisedButton;
if (this.state.publicityBusy) {
publicisedButton = <Spinner />;
}
let publicisedSection;
if (this.state.summary.user && this.state.summary.user.is_publicised) {
if (!this.state.publicityBusy) {
publicisedButton = <AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
onClick={this._onPubliciseOffClick}
>
{ _t("Unpublish") }
</AccessibleButton>;
}
publicisedSection = <div className="mx_GroupView_membershipSubSection">
{ _t("This group is published on your profile") }
<div className="mx_GroupView_membership_buttonContainer">
{ publicisedButton }
</div> </div>
</div>; </div>;
} else { } else if (group.myMembership === 'join' && this.state.editing) {
if (!this.state.publicityBusy) { const leaveButtonTooltip = this.state.isUserPrivileged ?
publicisedButton = <AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton" _t("You are an administrator of this community") :
onClick={this._onPubliciseOnClick} _t("You are a member of this community");
> const leaveButtonClasses = classnames({
{ _t("Publish") } "mx_RoomHeader_textButton": true,
</AccessibleButton>; "mx_GroupView_textButton": true,
} "mx_GroupView_leaveButton": true,
publicisedSection = <div className="mx_GroupView_membershipSubSection"> "mx_RoomHeader_textButton_danger": this.state.isUserPrivileged,
{ _t("This group is not published on your profile") } });
<div className="mx_GroupView_membership_buttonContainer">
{ publicisedButton }
</div>
</div>;
}
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_joined"> return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_joined">
<div className="mx_GroupView_membershipSubSection"> <div className="mx_GroupView_membershipSubSection">
<div className="mx_GroupView_membershipSection_description"> { /* Empty div for flex alignment */ }
{ youAreAMemberText } <div />
</div>
<div className="mx_GroupView_membership_buttonContainer"> <div className="mx_GroupView_membership_buttonContainer">
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton" <AccessibleButton
className={leaveButtonClasses}
onClick={this._onLeaveClick} onClick={this._onLeaveClick}
title={leaveButtonTooltip}
> >
{ _t("Leave") } { _t("Leave") }
</AccessibleButton> </AccessibleButton>
</div> </div>
</div> </div>
{ publicisedSection }
</div>; </div>;
} }
return null; return null;
}, },
_getLongDescriptionNode: function() {
const summary = this.state.summary;
let description = null;
if (summary.profile && summary.profile.long_description) {
description = sanitizedHtmlNode(summary.profile.long_description);
} else if (this.state.isUserPrivileged) {
description = <div
className="mx_GroupView_groupDesc_placeholder"
onClick={this._onEditClick}
>
{ _t(
'Your community hasn\'t got a Long Description, a HTML page to show to community members.<br />' +
'Click here to open settings and give it one!',
{},
{ 'br': <br /> },
) }
</div>;
}
const groupDescEditingClasses = classnames({
"mx_GroupView_groupDesc": true,
"mx_GroupView_groupDesc_disabled": !this.state.isUserPrivileged,
});
return this.state.editing ?
<div className={groupDescEditingClasses}>
<h3> { _t("Long Description (HTML)") } </h3>
<textarea
value={this.state.profileForm.long_description}
placeholder={_t(LONG_DESC_PLACEHOLDER)}
onChange={this._onLongDescChange}
tabIndex="4"
key="editLongDesc"
/>
</div> :
<div className="mx_GroupView_groupDesc">
{ description }
</div>;
},
render: function() { render: function() {
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Loader = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
if (this.state.summary === null && this.state.error === null || this.state.saving) { if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
return <Loader />; return <Spinner />;
} else if (this.state.summary) { } else if (this.state.summary) {
const summary = this.state.summary; const summary = this.state.summary;
let avatarNode; let avatarNode;
let nameNode; let nameNode;
let shortDescNode; let shortDescNode;
let roomBody; const bodyNodes = [
this._getMembershipSection(),
this._getGroupSection(),
];
const rightButtons = []; const rightButtons = [];
const headerClasses = { if (this.state.editing && this.state.isUserPrivileged) {
mx_GroupView_header: true,
};
if (this.state.editing) {
let avatarImage; let avatarImage;
if (this.state.uploadingAvatar) { if (this.state.uploadingAvatar) {
avatarImage = <Loader />; avatarImage = <Spinner />;
} else { } else {
const GroupAvatar = sdk.getComponent('avatars.GroupAvatar'); const GroupAvatar = sdk.getComponent('avatars.GroupAvatar');
avatarImage = <GroupAvatar groupId={this.props.groupId} avatarImage = <GroupAvatar groupId={this.props.groupId}
groupName={this.state.profileForm.name}
groupAvatarUrl={this.state.profileForm.avatar_url} groupAvatarUrl={this.state.profileForm.avatar_url}
width={48} height={48} resizeMethod='crop' width={48} height={48} resizeMethod='crop'
/>; />;
@ -831,18 +988,54 @@ export default React.createClass({
</div> </div>
</div> </div>
); );
nameNode = <input type="text"
value={this.state.profileForm.name} const EditableText = sdk.getComponent("elements.EditableText");
onChange={this._onNameChange}
placeholder={_t('Group Name')} nameNode = <EditableText ref="nameEditor"
className="mx_GroupView_editable"
placeholderClassName="mx_GroupView_placeholder"
placeholder={_t('Community Name')}
blurToCancel={false}
initialValue={this.state.profileForm.name}
onValueChanged={this._onNameChange}
tabIndex="1" tabIndex="1"
/>; dir="auto" />;
shortDescNode = <input type="text"
value={this.state.profileForm.short_description} shortDescNode = <EditableText ref="descriptionEditor"
onChange={this._onShortDescChange} className="mx_GroupView_editable"
placeholder={_t('Description')} placeholderClassName="mx_GroupView_placeholder"
placeholder={_t("Description")}
blurToCancel={false}
initialValue={this.state.profileForm.short_description}
onValueChanged={this._onShortDescChange}
tabIndex="2" tabIndex="2"
dir="auto" />;
} else {
const onGroupHeaderItemClick = this.state.isUserMember ? this._onEditClick : null;
const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null;
const groupName = summary.profile ? summary.profile.name : null;
avatarNode = <GroupAvatar
groupId={this.props.groupId}
groupAvatarUrl={groupAvatarUrl}
groupName={groupName}
onClick={onGroupHeaderItemClick}
width={48} height={48}
/>; />;
if (summary.profile && summary.profile.name) {
nameNode = <div onClick={onGroupHeaderItemClick}>
<span>{ summary.profile.name }</span>
<span className="mx_GroupView_header_groupid">
({ this.props.groupId })
</span>
</div>;
} else {
nameNode = <span onClick={onGroupHeaderItemClick}>{ this.props.groupId }</span>;
}
if (summary.profile && summary.profile.short_description) {
shortDescNode = <span onClick={onGroupHeaderItemClick}>{ summary.profile.short_description }</span>;
}
}
if (this.state.editing) {
rightButtons.push( rightButtons.push(
<AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton" <AccessibleButton className="mx_GroupView_textButton mx_RoomHeader_textButton"
onClick={this._onSaveClick} key="_saveButton" onClick={this._onSaveClick} key="_saveButton"
@ -856,47 +1049,11 @@ export default React.createClass({
width="18" height="18" alt={_t("Cancel")} /> width="18" height="18" alt={_t("Cancel")} />
</AccessibleButton>, </AccessibleButton>,
); );
roomBody = <div>
<textarea className="mx_GroupView_editLongDesc" value={this.state.profileForm.long_description}
onChange={this._onLongDescChange}
tabIndex="3"
/>
{ this._getFeaturedRoomsNode() }
{ this._getFeaturedUsersNode() }
</div>;
} else { } else {
const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null; if (summary.user && summary.user.membership === 'join') {
avatarNode = <GroupAvatar
groupId={this.props.groupId}
groupAvatarUrl={groupAvatarUrl}
width={48} height={48}
/>;
if (summary.profile && summary.profile.name) {
nameNode = <div>
<span>{ summary.profile.name }</span>
<span className="mx_GroupView_header_groupid">
({ this.props.groupId })
</span>
</div>;
} else {
nameNode = <span>{ this.props.groupId }</span>;
}
shortDescNode = <span>{ summary.profile.short_description }</span>;
let description = null;
if (summary.profile && summary.profile.long_description) {
description = sanitizedHtmlNode(summary.profile.long_description);
}
roomBody = <div>
{ this._getMembershipSection() }
<div className="mx_GroupView_groupDesc">{ description }</div>
{ this._getFeaturedRoomsNode() }
{ this._getFeaturedUsersNode() }
</div>;
if (summary.user && summary.user.is_privileged) {
rightButtons.push( rightButtons.push(
<AccessibleButton className="mx_GroupHeader_button" <AccessibleButton className="mx_GroupHeader_button"
onClick={this._onEditClick} title={_t("Edit Group")} key="_editButton" onClick={this._onEditClick} title={_t("Community Settings")} key="_editButton"
> >
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16" /> <TintableSvg src="img/icons-settings-room.svg" width="16" height="16" />
</AccessibleButton>, </AccessibleButton>,
@ -911,10 +1068,14 @@ export default React.createClass({
</AccessibleButton>, </AccessibleButton>,
); );
} }
headerClasses.mx_GroupView_header_view = true;
} }
const headerClasses = {
mx_GroupView_header: true,
mx_GroupView_header_view: !this.state.editing,
mx_GroupView_header_isUserMember: this.state.isUserMember,
};
return ( return (
<div className="mx_GroupView"> <div className="mx_GroupView">
<div className={classnames(headerClasses)}> <div className={classnames(headerClasses)}>
@ -935,24 +1096,26 @@ export default React.createClass({
{ rightButtons } { rightButtons }
</div> </div>
</div> </div>
{ roomBody } <GeminiScrollbar className="mx_GroupView_body">
{ bodyNodes }
</GeminiScrollbar>
</div> </div>
); );
} else if (this.state.error) { } else if (this.state.error) {
if (this.state.error.httpStatus === 404) { if (this.state.error.httpStatus === 404) {
return ( return (
<div className="mx_GroupView_error"> <div className="mx_GroupView_error">
Group { this.props.groupId } not found { _t('Community %(groupId)s not found', {groupId: this.props.groupId}) }
</div> </div>
); );
} else { } else {
let extraText; let extraText;
if (this.state.error.errcode === 'M_UNRECOGNIZED') { if (this.state.error.errcode === 'M_UNRECOGNIZED') {
extraText = <div>{ _t('This Home server does not support groups') }</div>; extraText = <div>{ _t('This Home server does not support communities') }</div>;
} }
return ( return (
<div className="mx_GroupView_error"> <div className="mx_GroupView_error">
Failed to load { this.props.groupId } { _t('Failed to load %(groupId)s', {groupId: this.props.groupId }) }
{ extraText } { extraText }
</div> </div>
); );

View file

@ -1,6 +1,7 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -17,9 +18,10 @@ limitations under the License.
import * as Matrix from 'matrix-js-sdk'; import * as Matrix from 'matrix-js-sdk';
import React from 'react'; import React from 'react';
import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';
import UserSettingsStore from '../../UserSettingsStore'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import KeyCode from '../../KeyCode';
import Notifier from '../../Notifier'; import Notifier from '../../Notifier';
import PageTypes from '../../PageTypes'; import PageTypes from '../../PageTypes';
import CallMediaHandler from '../../CallMediaHandler'; import CallMediaHandler from '../../CallMediaHandler';
@ -27,6 +29,7 @@ import sdk from '../../index';
import dis from '../../dispatcher'; 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";
/** /**
* 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
@ -37,7 +40,7 @@ import MatrixClientPeg from '../../MatrixClientPeg';
* *
* Components mounted below us can access the matrix client via the react context. * Components mounted below us can access the matrix client via the react context.
*/ */
export default React.createClass({ const LoggedInView = React.createClass({
displayName: 'LoggedInView', displayName: 'LoggedInView',
propTypes: { propTypes: {
@ -73,7 +76,7 @@ export default React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
// use compact timeline view // use compact timeline view
useCompactLayout: UserSettingsStore.getSyncedSetting('useCompactLayout'), useCompactLayout: SettingsStore.getValue('useCompactLayout'),
}; };
}, },
@ -152,13 +155,7 @@ export default React.createClass({
*/ */
let handled = false; let handled = false;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
let ctrlCmdOnly;
if (isMac) {
ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
switch (ev.keyCode) { switch (ev.keyCode) {
case KeyCode.UP: case KeyCode.UP:
@ -212,6 +209,7 @@ export default React.createClass({
}, },
render: function() { render: function() {
const TagPanel = sdk.getComponent('structures.TagPanel');
const LeftPanel = sdk.getComponent('structures.LeftPanel'); const LeftPanel = sdk.getComponent('structures.LeftPanel');
const RightPanel = sdk.getComponent('structures.RightPanel'); const RightPanel = sdk.getComponent('structures.RightPanel');
const RoomView = sdk.getComponent('structures.RoomView'); const RoomView = sdk.getComponent('structures.RoomView');
@ -239,22 +237,23 @@ export default React.createClass({
oobData={this.props.roomOobData} oobData={this.props.roomOobData}
eventPixelOffset={this.props.initialEventPixelOffset} eventPixelOffset={this.props.initialEventPixelOffset}
key={this.props.currentRoomId || 'roomview'} key={this.props.currentRoomId || 'roomview'}
opacity={this.props.middleOpacity} disabled={this.props.middleDisabled}
collapsedRhs={this.props.collapseRhs} collapsedRhs={this.props.collapseRhs}
ConferenceHandler={this.props.ConferenceHandler} ConferenceHandler={this.props.ConferenceHandler}
/>; />;
if (!this.props.collapseRhs) right_panel = <RightPanel roomId={this.props.currentRoomId} opacity={this.props.rightOpacity} />; if (!this.props.collapseRhs) {
right_panel = <RightPanel roomId={this.props.currentRoomId} disabled={this.props.rightDisabled} />;
}
break; break;
case PageTypes.UserSettings: case PageTypes.UserSettings:
page_element = <UserSettings page_element = <UserSettings
onClose={this.props.onUserSettingsClose} onClose={this.props.onUserSettingsClose}
brand={this.props.config.brand} brand={this.props.config.brand}
enableLabs={this.props.config.enableLabs}
referralBaseUrl={this.props.config.referralBaseUrl} referralBaseUrl={this.props.config.referralBaseUrl}
teamToken={this.props.teamToken} teamToken={this.props.teamToken}
/>; />;
if (!this.props.collapseRhs) right_panel = <RightPanel opacity={this.props.rightOpacity} />; if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break; break;
case PageTypes.MyGroups: case PageTypes.MyGroups:
@ -266,7 +265,7 @@ export default React.createClass({
onRoomCreated={this.props.onRoomCreated} onRoomCreated={this.props.onRoomCreated}
collapsedRhs={this.props.collapseRhs} collapsedRhs={this.props.collapseRhs}
/>; />;
if (!this.props.collapseRhs) right_panel = <RightPanel opacity={this.props.rightOpacity} />; if (!this.props.collapseRhs) right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break; break;
case PageTypes.RoomDirectory: case PageTypes.RoomDirectory:
@ -294,14 +293,15 @@ export default React.createClass({
case PageTypes.UserView: case PageTypes.UserView:
page_element = null; // deliberately null for now page_element = null; // deliberately null for now
right_panel = <RightPanel opacity={this.props.rightOpacity} />; right_panel = <RightPanel disabled={this.props.rightDisabled} />;
break; break;
case PageTypes.GroupView: case PageTypes.GroupView:
page_element = <GroupView page_element = <GroupView
groupId={this.props.currentGroupId} groupId={this.props.currentGroupId}
isNew={this.props.currentGroupIsNew}
collapsedRhs={this.props.collapseRhs} collapsedRhs={this.props.collapseRhs}
/>; />;
if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} opacity={this.props.rightOpacity} />; if (!this.props.collapseRhs) right_panel = <RightPanel groupId={this.props.currentGroupId} disabled={this.props.rightDisabled} />;
break; break;
} }
@ -331,10 +331,11 @@ export default React.createClass({
<div className='mx_MatrixChat_wrapper'> <div className='mx_MatrixChat_wrapper'>
{ topBar } { topBar }
<div className={bodyClasses}> <div className={bodyClasses}>
{ SettingsStore.isFeatureEnabled("feature_tag_panel") ? <TagPanel /> : <div /> }
<LeftPanel <LeftPanel
selectedRoom={this.props.currentRoomId} selectedRoom={this.props.currentRoomId}
collapsed={this.props.collapseLhs || false} collapsed={this.props.collapseLhs || false}
opacity={this.props.leftOpacity} disabled={this.props.leftDisabled}
/> />
<main className='mx_MatrixChat_middlePanel'> <main className='mx_MatrixChat_middlePanel'>
{ page_element } { page_element }
@ -345,3 +346,5 @@ export default React.createClass({
); );
}, },
}); });
export default DragDropContext(HTML5Backend)(LoggedInView);

View file

@ -22,7 +22,6 @@ import React from 'react';
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics"; import Analytics from "../../Analytics";
import UserSettingsStore from '../../UserSettingsStore';
import MatrixClientPeg from "../../MatrixClientPeg"; import MatrixClientPeg from "../../MatrixClientPeg";
import PlatformPeg from "../../PlatformPeg"; import PlatformPeg from "../../PlatformPeg";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
@ -41,9 +40,9 @@ require('../../stores/LifecycleStore');
import PageTypes from '../../PageTypes'; import PageTypes from '../../PageTypes';
import createRoom from "../../createRoom"; import createRoom from "../../createRoom";
import * as UDEHandler from '../../UnknownDeviceErrorHandler';
import KeyRequestHandler from '../../KeyRequestHandler'; import KeyRequestHandler from '../../KeyRequestHandler';
import { _t, getCurrentLanguage } from '../../languageHandler'; import { _t, getCurrentLanguage } from '../../languageHandler';
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
const VIEWS = { const VIEWS = {
@ -74,7 +73,17 @@ const VIEWS = {
LOGGED_IN: 6, LOGGED_IN: 6,
}; };
module.exports = React.createClass({ // Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require
// re-factoring to be included in this list in future.
const ONBOARDING_FLOW_STARTERS = [
'view_user_settings',
'view_create_chat',
'view_create_room',
'view_create_group',
];
export default React.createClass({
// we export this so that the integration tests can use it :-S // we export this so that the integration tests can use it :-S
statics: { statics: {
VIEWS: VIEWS, VIEWS: VIEWS,
@ -145,9 +154,9 @@ module.exports = React.createClass({
collapseLhs: false, collapseLhs: false,
collapseRhs: false, collapseRhs: false,
leftOpacity: 1.0, leftDisabled: false,
middleOpacity: 1.0, middleDisabled: false,
rightOpacity: 1.0, rightDisabled: false,
version: null, version: null,
newVersion: null, newVersion: null,
@ -213,7 +222,7 @@ module.exports = React.createClass({
componentWillMount: function() { componentWillMount: function() {
SdkConfig.put(this.props.config); SdkConfig.put(this.props.config);
if (!UserSettingsStore.getLocalSetting('analyticsOptOut', false)) Analytics.enable(); if (!SettingsStore.getValue("analyticsOptOut")) Analytics.enable();
// Used by _viewRoom before getting state from sync // Used by _viewRoom before getting state from sync
this.firstSyncComplete = false; this.firstSyncComplete = false;
@ -276,11 +285,15 @@ module.exports = React.createClass({
this._windowWidth = 10000; this._windowWidth = 10000;
this.handleResize(); this.handleResize();
window.addEventListener('resize', this.handleResize); window.addEventListener('resize', this.handleResize);
// check we have the right tint applied for this theme.
// N.B. we don't call the whole of setTheme() here as we may be
// racing with the theme CSS download finishing from index.js
Tinter.tint();
}, },
componentDidMount: function() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
UDEHandler.startListening();
this.focusComposer = false; this.focusComposer = false;
@ -301,7 +314,7 @@ module.exports = React.createClass({
// the first thing to do is to try the token params in the query-string // the first thing to do is to try the token params in the query-string
Lifecycle.attemptTokenLogin(this.props.realQueryParams).then((loggedIn) => { Lifecycle.attemptTokenLogin(this.props.realQueryParams).then((loggedIn) => {
if(loggedIn) { if (loggedIn) {
this.props.onTokenLoginCompleted(); this.props.onTokenLoginCompleted();
// don't do anything else until the page reloads - just stay in // don't do anything else until the page reloads - just stay in
@ -346,7 +359,6 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
Lifecycle.stopMatrixClient(); Lifecycle.stopMatrixClient();
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
UDEHandler.stopListening();
window.removeEventListener("focus", this.onFocus); window.removeEventListener("focus", this.onFocus);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
}, },
@ -374,6 +386,22 @@ module.exports = React.createClass({
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
// Start the onboarding process for certain actions
if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() &&
ONBOARDING_FLOW_STARTERS.includes(payload.action)
) {
// This will cause `payload` to be dispatched later, once a
// sync has reached the "prepared" state. Setting a matrix ID
// will cause a full login and sync and finally the deferred
// action will be dispatched.
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: payload,
});
dis.dispatch({action: 'view_set_mxid'});
return;
}
switch (payload.action) { switch (payload.action) {
case 'logout': case 'logout':
Lifecycle.logout(); Lifecycle.logout();
@ -463,22 +491,17 @@ module.exports = React.createClass({
this._viewIndexedRoom(payload.roomIndex); this._viewIndexedRoom(payload.roomIndex);
break; break;
case 'view_user_settings': case 'view_user_settings':
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_user_settings',
},
});
dis.dispatch({action: 'view_set_mxid'});
break;
}
this._setPage(PageTypes.UserSettings); this._setPage(PageTypes.UserSettings);
this.notifyNewScreen('settings'); this.notifyNewScreen('settings');
break; break;
case 'view_create_room': case 'view_create_room':
this._createRoom(); this._createRoom();
break; break;
case 'view_create_group': {
const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog");
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
}
break;
case 'view_room_directory': case 'view_room_directory':
this._setPage(PageTypes.RoomDirectory); this._setPage(PageTypes.RoomDirectory);
this.notifyNewScreen('directory'); this.notifyNewScreen('directory');
@ -490,7 +513,10 @@ module.exports = React.createClass({
case 'view_group': case 'view_group':
{ {
const groupId = payload.group_id; const groupId = payload.group_id;
this.setState({currentGroupId: groupId}); this.setState({
currentGroupId: groupId,
currentGroupIsNew: payload.group_is_new,
});
this._setPage(PageTypes.GroupView); this._setPage(PageTypes.GroupView);
this.notifyNewScreen('group/' + groupId); this.notifyNewScreen('group/' + groupId);
} }
@ -506,7 +532,7 @@ module.exports = React.createClass({
this._chatCreateOrReuse(payload.user_id, payload.go_home_on_cancel); this._chatCreateOrReuse(payload.user_id, payload.go_home_on_cancel);
break; break;
case 'view_create_chat': case 'view_create_chat':
this._createChat(); showStartChatInviteDialog();
break; break;
case 'view_invite': case 'view_invite':
showRoomInviteDialog(payload.roomId); showRoomInviteDialog(payload.roomId);
@ -534,12 +560,11 @@ module.exports = React.createClass({
collapseRhs: false, collapseRhs: false,
}); });
break; break;
case 'ui_opacity': { case 'panel_disable': {
const sideDefault = payload.sideOpacity >= 0.0 ? payload.sideOpacity : 1.0;
this.setState({ this.setState({
leftOpacity: payload.leftOpacity >= 0.0 ? payload.leftOpacity : sideDefault, leftDisabled: payload.leftDisabled || payload.sideDisabled || false,
middleOpacity: payload.middleOpacity || 1.0, middleDisabled: payload.middleDisabled || false,
rightOpacity: payload.rightOpacity >= 0.0 ? payload.rightOpacity : sideDefault, rightDisabled: payload.rightDisabled || payload.sideDisabled || false,
}); });
break; } break; }
case 'set_theme': case 'set_theme':
@ -567,6 +592,9 @@ module.exports = React.createClass({
this._onWillStartClient(); this._onWillStartClient();
}); });
break; break;
case 'client_started':
this._onClientStarted();
break;
case 'new_version': case 'new_version':
this.onVersion( this.onVersion(
payload.currentVersion, payload.newVersion, payload.currentVersion, payload.newVersion,
@ -748,31 +776,7 @@ module.exports = React.createClass({
}).close; }).close;
}, },
_createChat: function() {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_create_chat',
},
});
dis.dispatch({action: 'view_set_mxid'});
return;
}
showStartChatInviteDialog();
},
_createRoom: function() { _createRoom: function() {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({
action: 'do_after_sync_prepared',
deferred_action: {
action: 'view_create_room',
},
});
dis.dispatch({action: 'view_set_mxid'});
return;
}
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
onFinished: (shouldCreate, name, noFederate) => { onFinished: (shouldCreate, name, noFederate) => {
@ -888,7 +892,7 @@ module.exports = React.createClass({
*/ */
_onSetTheme: function(theme) { _onSetTheme: function(theme) {
if (!theme) { if (!theme) {
theme = 'light'; theme = SettingsStore.getValue("theme");
} }
// look for the stylesheet elements. // look for the stylesheet elements.
@ -911,18 +915,49 @@ module.exports = React.createClass({
// disable all of them first, then enable the one we want. Chrome only // disable all of them first, then enable the one we want. Chrome only
// bothers to do an update on a true->false transition, so this ensures // bothers to do an update on a true->false transition, so this ensures
// that we get exactly one update, at the right time. // that we get exactly one update, at the right time.
//
// ^ This comment was true when we used to use alternative stylesheets
// for the CSS. Nowadays we just set them all as disabled in index.html
// and enable them as needed. It might be cleaner to disable them all
// at the same time to prevent loading two themes simultaneously and
// having them interact badly... but this causes a flash of unstyled app
// which is even uglier. So we don't.
Object.values(styleElements).forEach((a) => {
a.disabled = true;
});
styleElements[theme].disabled = false; styleElements[theme].disabled = false;
if (theme === 'dark') { const switchTheme = function() {
// abuse the tinter to change all the SVG's #fff to #2d2d2d // we re-enable our theme here just in case we raced with another
// XXX: obviously this shouldn't be hardcoded here. // theme set request as per https://github.com/vector-im/riot-web/issues/5601.
Tinter.tintSvgWhite('#2d2d2d'); // We could alternatively lock or similar to stop the race, but
} else { // this is probably good enough for now.
Tinter.tintSvgWhite('#ffffff'); styleElements[theme].disabled = false;
Object.values(styleElements).forEach((a) => {
if (a == styleElements[theme]) return;
a.disabled = true;
});
Tinter.setTheme(theme);
};
// turns out that Firefox preloads the CSS for link elements with
// the disabled attribute, but Chrome doesn't.
let cssLoaded = false;
styleElements[theme].onload = () => {
switchTheme();
};
for (let i = 0; i < document.styleSheets.length; i++) {
const ss = document.styleSheets[i];
if (ss && ss.href === styleElements[theme].href) {
cssLoaded = true;
break;
}
}
if (cssLoaded) {
styleElements[theme].onload = undefined;
switchTheme();
} }
}, },
@ -1030,10 +1065,10 @@ module.exports = React.createClass({
// this if we are not scrolled up in the view. To find out, delegate to // this if we are not scrolled up in the view. To find out, delegate to
// the timeline panel. If the timeline panel doesn't exist, then we assume // the timeline panel. If the timeline panel doesn't exist, then we assume
// it is safe to reset the timeline. // it is safe to reset the timeline.
if (!self.refs.loggedInView) { if (!self._loggedInView) {
return true; return true;
} }
return self.refs.loggedInView.canResetTimelineInRoom(roomId); return self._loggedInView.getDecoratedComponentInstance().canResetTimelineInRoom(roomId);
}); });
cli.on('sync', function(state, prevState) { cli.on('sync', function(state, prevState) {
@ -1093,6 +1128,65 @@ module.exports = React.createClass({
cli.on("crypto.roomKeyRequestCancellation", (req) => { cli.on("crypto.roomKeyRequestCancellation", (req) => {
krh.handleKeyRequestCancellation(req); krh.handleKeyRequestCancellation(req);
}); });
cli.on("Room", (room) => {
if (MatrixClientPeg.get().isCryptoEnabled()) {
const blacklistEnabled = SettingsStore.getValueAt(
SettingLevel.ROOM_DEVICE,
"blacklistUnverifiedDevices",
room.roomId,
/*explicit=*/true,
);
room.setBlacklistUnverifiedDevices(blacklistEnabled);
}
});
cli.on("crypto.warning", (type) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
switch (type) {
case 'CRYPTO_WARNING_ACCOUNT_MIGRATED':
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
title: _t('Cryptography data migrated'),
description: _t(
"A one-off migration of cryptography data has been performed. "+
"End-to-end encryption will not work if you go back to an older "+
"version of Riot. If you need to use end-to-end cryptography on "+
"an older version, log out of Riot first. To retain message history, "+
"export and re-import your keys.",
),
});
break;
case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
title: _t('Old cryptography data detected'),
description: _t(
"Data from an older version of Riot has been detected. "+
"This will have caused end-to-end cryptography to malfunction "+
"in the older version. End-to-end encrypted messages exchanged "+
"recently whilst using the older version may not be decryptable "+
"in this version. This may also cause messages exchanged with this "+
"version to fail. If you experience problems, log out and back in "+
"again. To retain message history, export and re-import your keys.",
),
});
break;
}
});
},
/**
* Called shortly after the matrix client has started. Useful for
* setting up anything that requires the client to be started.
* @private
*/
_onClientStarted: function() {
const cli = MatrixClientPeg.get();
if (cli.isCryptoEnabled()) {
const blacklistEnabled = SettingsStore.getValueAt(
SettingLevel.DEVICE,
"blacklistUnverifiedDevices",
);
cli.setGlobalBlacklistUnverifiedDevices(blacklistEnabled);
}
}, },
showScreen: function(screen, params) { showScreen: function(screen, params) {
@ -1332,13 +1426,6 @@ module.exports = React.createClass({
cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => { cli.sendEvent(roomId, event.getType(), event.getContent()).done(() => {
dis.dispatch({action: 'message_sent'}); dis.dispatch({action: 'message_sent'});
}, (err) => { }, (err) => {
if (err.name === 'UnknownDeviceError') {
dis.dispatch({
action: 'unknown_device_error',
err: err,
room: cli.getRoom(roomId),
});
}
dis.dispatch({action: 'message_send_failed'}); dis.dispatch({action: 'message_send_failed'});
}); });
}, },
@ -1400,6 +1487,10 @@ module.exports = React.createClass({
return this.props.makeRegistrationUrl(params); return this.props.makeRegistrationUrl(params);
}, },
_collectLoggedInView: function(ref) {
this._loggedInView = ref;
},
render: function() { render: function() {
// console.log(`Rendering MatrixChat with view ${this.state.view}`); // console.log(`Rendering MatrixChat with view ${this.state.view}`);
@ -1432,7 +1523,7 @@ module.exports = React.createClass({
*/ */
const LoggedInView = sdk.getComponent('structures.LoggedInView'); const LoggedInView = sdk.getComponent('structures.LoggedInView');
return ( return (
<LoggedInView ref="loggedInView" matrixClient={MatrixClientPeg.get()} <LoggedInView ref={this._collectLoggedInView} matrixClient={MatrixClientPeg.get()}
onRoomCreated={this.onRoomCreated} onRoomCreated={this.onRoomCreated}
onUserSettingsClose={this.onUserSettingsClose} onUserSettingsClose={this.onUserSettingsClose}
onRegistered={this.onRegistered} onRegistered={this.onRegistered}

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import UserSettingsStore from '../../UserSettingsStore'; import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent'; import shouldHideEvent from '../../shouldHideEvent';
import dis from "../../dispatcher"; import dis from "../../dispatcher";
import sdk from '../../index'; import sdk from '../../index';
@ -78,9 +78,6 @@ module.exports = React.createClass({
// callback which is called when more content is needed. // callback which is called when more content is needed.
onFillRequest: React.PropTypes.func, onFillRequest: React.PropTypes.func,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
// className for the panel // className for the panel
className: React.PropTypes.string.isRequired, className: React.PropTypes.string.isRequired,
@ -112,8 +109,6 @@ module.exports = React.createClass({
// Velocity requires // Velocity requires
this._readMarkerGhostNode = null; this._readMarkerGhostNode = null;
this._syncedSettings = UserSettingsStore.getSyncedSettings();
this._isMounted = true; this._isMounted = true;
}, },
@ -253,7 +248,7 @@ module.exports = React.createClass({
// Always show highlighted event // Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true; if (this.props.highlightedEventId === mxEv.getId()) return true;
return !shouldHideEvent(mxEv, this._syncedSettings); return !shouldHideEvent(mxEv);
}, },
_getEventTiles: function() { _getEventTiles: function() {
@ -353,7 +348,7 @@ module.exports = React.createClass({
} }
if (!isMembershipChange(collapsedMxEv) || if (!isMembershipChange(collapsedMxEv) ||
this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getDate())) { this._wantsDateSeparator(mxEv, collapsedMxEv.getDate())) {
break; break;
} }
@ -376,9 +371,7 @@ module.exports = React.createClass({
// of MemberEventListSummary, render each member event as if the previous // of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the // one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeperator is inserted. // timestamp of the current event, and no DateSeperator is inserted.
const ret = this._getTilesForEvent(e, e, e === lastShownEvent); return this._getTilesForEvent(e, e, e === lastShownEvent);
prevEvent = e;
return ret;
}).reduce((a, b) => a.concat(b)); }).reduce((a, b) => a.concat(b));
if (eventTiles.length === 0) { if (eventTiles.length === 0) {
@ -397,6 +390,7 @@ module.exports = React.createClass({
ret.push(this._getReadMarkerTile(visible)); ret.push(this._getReadMarkerTile(visible));
} }
prevEvent = mxEv;
continue; continue;
} }
@ -649,12 +643,13 @@ module.exports = React.createClass({
} }
const style = this.props.hidden ? { display: 'none' } : {}; const style = this.props.hidden ? { display: 'none' } : {};
style.opacity = this.props.opacity;
let className = this.props.className + " mx_fadable"; const className = classNames(
if (this.props.alwaysShowTimestamps) { this.props.className,
className += " mx_MessagePanel_alwaysShowTimestamps"; {
} "mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps,
},
);
return ( return (
<ScrollPanel ref="scrollPanel" className={className} <ScrollPanel ref="scrollPanel" className={className}

View file

@ -15,33 +15,12 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import GeminiScrollbar from 'react-gemini-scrollbar';
import sdk from '../../index'; import sdk from '../../index';
import { _t, _tJsx } from '../../languageHandler'; import { _t } from '../../languageHandler';
import dis from '../../dispatcher';
import withMatrixClient from '../../wrappers/withMatrixClient'; import withMatrixClient from '../../wrappers/withMatrixClient';
import AccessibleButton from '../views/elements/AccessibleButton'; import AccessibleButton from '../views/elements/AccessibleButton';
import dis from '../../dispatcher';
import PropTypes from 'prop-types';
import Modal from '../../Modal';
const GroupTile = React.createClass({
displayName: 'GroupTile',
propTypes: {
groupId: PropTypes.string.isRequired,
},
onClick: function(e) {
e.preventDefault();
dis.dispatch({
action: 'view_group',
group_id: this.props.groupId,
});
},
render: function() {
return <a onClick={this.onClick} href="#">{ this.props.groupId }</a>;
},
});
export default withMatrixClient(React.createClass({ export default withMatrixClient(React.createClass({
displayName: 'MyGroups', displayName: 'MyGroups',
@ -62,14 +41,18 @@ export default withMatrixClient(React.createClass({
}, },
_onCreateGroupClick: function() { _onCreateGroupClick: function() {
const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog"); dis.dispatch({action: 'view_create_group'});
Modal.createTrackedDialog('Create Group', '', CreateGroupDialog);
}, },
_fetch: function() { _fetch: function() {
this.props.matrixClient.getJoinedGroups().done((result) => { this.props.matrixClient.getJoinedGroups().done((result) => {
this.setState({groups: result.groups, error: null}); this.setState({groups: result.groups, error: null});
}, (err) => { }, (err) => {
if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') {
// Indicate that the guest isn't in any groups (which should be true)
this.setState({groups: [], error: null});
return;
}
this.setState({groups: null, error: err}); this.setState({groups: null, error: err});
}); });
}, },
@ -78,62 +61,70 @@ export default withMatrixClient(React.createClass({
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader');
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const GroupTile = sdk.getComponent("groups.GroupTile");
let content; let content;
let contentHeader;
if (this.state.groups) { if (this.state.groups) {
const groupNodes = []; const groupNodes = [];
this.state.groups.forEach((g) => { this.state.groups.forEach((g) => {
groupNodes.push( groupNodes.push(<GroupTile groupId={g} />);
<div key={g}>
<GroupTile groupId={g} />
</div>,
);
}); });
content = <div> contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
<div>{ _t('You are a member of these groups:') }</div> content = groupNodes.length > 0 ?
<GeminiScrollbar className="mx_MyGroups_joinedGroups">
{ groupNodes } { groupNodes }
</GeminiScrollbar> :
<div className="mx_MyGroups_placeholder">
{ _t(
"You're not currently a member of any communities.",
) }
</div>; </div>;
} else if (this.state.error) { } else if (this.state.error) {
content = <div className="mx_MyGroups_error"> content = <div className="mx_MyGroups_error">
{ _t('Error whilst fetching joined groups') } { _t('Error whilst fetching joined communities') }
</div>; </div>;
} else { } else {
content = <Loader />; content = <Loader />;
} }
return <div className="mx_MyGroups"> return <div className="mx_MyGroups">
<SimpleRoomHeader title={_t("Groups")} icon="img/icons-groups.svg" /> <SimpleRoomHeader title={_t("Communities")} icon="img/icons-groups.svg" />
<div className='mx_MyGroups_joinCreateBox'> <div className='mx_MyGroups_header'>
<div className="mx_MyGroups_createBox"> <div className="mx_MyGroups_headerCard">
<div className="mx_MyGroups_joinCreateHeader"> <AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onCreateGroupClick}>
{ _t('Create a new group') }
</div>
<AccessibleButton className='mx_MyGroups_joinCreateButton' onClick={this._onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" /> <TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton> </AccessibleButton>
<div className="mx_MyGroups_headerCard_content">
<div className="mx_MyGroups_headerCard_header">
{ _t('Create a new community') }
</div>
{ _t( { _t(
'Create a group to represent your community! '+ 'Create a community to group together users and rooms! ' +
'Define a set of rooms and your own custom homepage '+ 'Build a custom homepage to mark out your space in the Matrix universe.',
'to mark out your space in the Matrix universe.',
) } ) }
</div> </div>
<div className="mx_MyGroups_joinBox">
<div className="mx_MyGroups_joinCreateHeader">
{ _t('Join an existing group') }
</div> </div>
<AccessibleButton className='mx_MyGroups_joinCreateButton' onClick={this._onJoinGroupClick}> <div className="mx_MyGroups_joinBox mx_MyGroups_headerCard">
<AccessibleButton className='mx_MyGroups_headerCard_button' onClick={this._onJoinGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="50" height="50" /> <TintableSvg src="img/icons-create-room.svg" width="50" height="50" />
</AccessibleButton> </AccessibleButton>
{ _tJsx( <div className="mx_MyGroups_headerCard_content">
'To join an existing group you\'ll have to '+ <div className="mx_MyGroups_headerCard_header">
'know its group identifier; this will look '+ { _t('Join an existing community') }
</div>
{ _t(
'To join an existing community you\'ll have to '+
'know its community identifier; this will look '+
'something like <i>+example:matrix.org</i>.', 'something like <i>+example:matrix.org</i>.',
/<i>(.*)<\/i>/, {},
(sub) => <i>{ sub }</i>, { 'i': (sub) => <i>{ sub }</i> })
) } }
</div>
</div> </div>
</div> </div>
<div className="mx_MyGroups_content"> <div className="mx_MyGroups_content">
{ contentHeader }
{ content } { content }
</div> </div>
</div>; </div>;

View file

@ -45,7 +45,6 @@ const NotificationPanel = React.createClass({
manageReadMarkers={false} manageReadMarkers={false}
timelineSet={timelineSet} timelineSet={timelineSet}
showUrlPreview = {false} showUrlPreview = {false}
opacity={this.props.opacity}
tileShape="notif" tileShape="notif"
empty={_t('You have no visible notifications')} empty={_t('You have no visible notifications')}
/> />

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 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,17 +16,26 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { _t, _tJsx } from '../../languageHandler'; import Matrix from 'matrix-js-sdk';
import { _t } 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 { showUnknownDeviceDialogForMessages } from '../../cryptodevices';
const HIDE_DEBOUNCE_MS = 10000;
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2; const STATUS_BAR_EXPANDED_LARGE = 2;
function getUnsentMessages(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === Matrix.EventStatus.NOT_SENT;
});
};
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomStatusBar', displayName: 'RoomStatusBar',
@ -36,16 +46,14 @@ module.exports = React.createClass({
// the number of messages which have arrived since we've been scrolled up // the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number, numUnreadMessages: React.PropTypes.number,
// string to display when there are messages in the room which had errors on send
unsentMessageError: React.PropTypes.string,
// the number of messages not sent.
numUnsentMessages: React.PropTypes.number,
// this is true if we are fully scrolled-down, and are looking at // this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline. // the end of the live timeline.
atEndOfLiveTimeline: React.PropTypes.bool, atEndOfLiveTimeline: React.PropTypes.bool,
// This is true when the user is alone in the room, but has also sent a message.
// Used to suggest to the user to invite someone
sentMessageAndIsAlone: React.PropTypes.bool,
// true if there is an active call in this room (means we show // true if there is an active call in this room (means we show
// the 'Active Call' text in the status bar if there is nothing // the 'Active Call' text in the status bar if there is nothing
// more interesting) // more interesting)
@ -63,6 +71,14 @@ module.exports = React.createClass({
// 'unsent messages' bar // 'unsent messages' bar
onCancelAllClick: React.PropTypes.func, onCancelAllClick: React.PropTypes.func,
// callback for when the user clicks on the 'invite others' button in the
// 'you are alone' bar
onInviteClick: React.PropTypes.func,
// callback for when the user clicks on the 'stop warning me' button in the
// 'you are alone' bar
onStopWarningClick: React.PropTypes.func,
// callback for when the user clicks on the 'scroll to bottom' button // callback for when the user clicks on the 'scroll to bottom' button
onScrollToBottomClick: React.PropTypes.func, onScrollToBottomClick: React.PropTypes.func,
@ -90,12 +106,14 @@ module.exports = React.createClass({
return { return {
syncState: MatrixClientPeg.get().getSyncState(), syncState: MatrixClientPeg.get().getSyncState(),
usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room),
unsentMessages: getUnsentMessages(this.props.room),
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("sync", this.onSyncStateChange);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
this._checkSize(); this._checkSize();
}, },
@ -110,6 +128,7 @@ module.exports = React.createClass({
if (client) { if (client) {
client.removeListener("sync", this.onSyncStateChange); client.removeListener("sync", this.onSyncStateChange);
client.removeListener("RoomMember.typing", this.onRoomMemberTyping); client.removeListener("RoomMember.typing", this.onRoomMemberTyping);
client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated);
} }
}, },
@ -128,6 +147,26 @@ module.exports = React.createClass({
}); });
}, },
_onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room);
},
_onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room);
},
_onShowDevicesClick: function() {
showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
},
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
if (room.roomId !== this.props.room.roomId) return;
this.setState({
unsentMessages: getUnsentMessages(this.props.room),
});
},
// Check whether current size is greater than 0, if yes call props.onVisible // Check whether current size is greater than 0, if yes call props.onVisible
_checkSize: function() { _checkSize: function() {
if (this.props.onVisible && this._getSize()) { if (this.props.onVisible && this._getSize()) {
@ -143,10 +182,11 @@ module.exports = React.createClass({
(this.state.usersTyping.length > 0) || (this.state.usersTyping.length > 0) ||
this.props.numUnreadMessages || this.props.numUnreadMessages ||
!this.props.atEndOfLiveTimeline || !this.props.atEndOfLiveTimeline ||
this.props.hasActiveCall this.props.hasActiveCall ||
this.props.sentMessageAndIsAlone
) { ) {
return STATUS_BAR_EXPANDED; return STATUS_BAR_EXPANDED;
} else if (this.props.unsentMessageError) { } else if (this.state.unsentMessages.length > 0) {
return STATUS_BAR_EXPANDED_LARGE; return STATUS_BAR_EXPANDED_LARGE;
} }
return STATUS_BAR_HIDDEN; return STATUS_BAR_HIDDEN;
@ -232,6 +272,61 @@ module.exports = React.createClass({
return avatars; return avatars;
}, },
_getUnsentMessageContent: function() {
const unsentMessages = this.state.unsentMessages;
if (!unsentMessages.length) return null;
let title;
let content;
const hasUDE = unsentMessages.some((m) => {
return m.error && m.error.name === "UnknownDeviceError";
});
if (hasUDE) {
title = _t("Message not sent due to unknown devices being present");
content = _t(
"<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.",
{},
{
'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
} else {
if (
unsentMessages.length === 1 &&
unsentMessages[0].error &&
unsentMessages[0].error.data &&
unsentMessages[0].error.data.error
) {
title = unsentMessages[0].error.data.error;
} else {
title = _t("Some of your messages have not been sent.");
}
content = _t("<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
"You can also select individual messages to resend or cancel.",
{},
{
'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,
'cancelText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
},
);
}
return <div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title={_t("Warning")} alt={_t("Warning")} />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ title }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ content }
</div>
</div>;
},
// return suitable content for the main (text) part of the status bar. // return suitable content for the main (text) part of the status bar.
_getContent: function() { _getContent: function() {
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
@ -254,26 +349,8 @@ module.exports = React.createClass({
); );
} }
if (this.props.unsentMessageError) { if (this.state.unsentMessages.length > 0) {
let resendStr = "<a>Resend message</a> or <a>cancel message</a> now."; return this._getUnsentMessageContent();
if (this.props.numUnsentMessages > 1) resendStr = "<a>Resend all</a> or <a>cancel all</a> now. You can also select individual messages to resend or cancel.";
return (
<div className="mx_RoomStatusBar_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ " />
<div className="mx_RoomStatusBar_connectionLostBar_title">
{ this.props.unsentMessageError }
</div>
<div className="mx_RoomStatusBar_connectionLostBar_desc">
{ _tJsx(resendStr,
[/<a>(.*?)<\/a>/, /<a>(.*?)<\/a>/],
[
(sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this.props.onResendAllClick}>{ sub }</a>,
(sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this.props.onCancelAllClick}>{ sub }</a>,
],
) }
</div>
</div>
);
} }
// unread count trumps who is typing since the unread count is only // unread count trumps who is typing since the unread count is only
@ -310,10 +387,27 @@ module.exports = React.createClass({
); );
} }
// If you're alone in the room, and have sent a message, suggest to invite someone
if (this.props.sentMessageAndIsAlone) {
return (
<div className="mx_RoomStatusBar_isAlone">
{ _t("There's no one else here! Would you like to <inviteText>invite others</inviteText> " +
"or <nowarnText>stop warning about the empty room</nowarnText>?",
{},
{
'inviteText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="invite" onClick={this.props.onInviteClick}>{ sub }</a>,
'nowarnText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="nowarn" onClick={this.props.onStopWarningClick}>{ sub }</a>,
},
) }
</div>
);
}
return null; return null;
}, },
render: function() { render: function() {
const content = this._getContent(); const content = this._getContent();
const indicator = this._getIndicator(this.state.usersTyping.length > 0); const indicator = this._getIndicator(this.state.usersTyping.length > 0);

View file

@ -26,28 +26,24 @@ const React = require("react");
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
import Promise from 'bluebird'; import Promise from 'bluebird';
const classNames = require("classnames"); const classNames = require("classnames");
const Matrix = require("matrix-js-sdk");
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
const UserSettingsStore = require('../../UserSettingsStore');
const MatrixClientPeg = require("../../MatrixClientPeg"); const MatrixClientPeg = require("../../MatrixClientPeg");
const ContentMessages = require("../../ContentMessages"); const ContentMessages = require("../../ContentMessages");
const Modal = require("../../Modal"); const Modal = require("../../Modal");
const sdk = require('../../index'); const sdk = require('../../index');
const CallHandler = require('../../CallHandler'); const CallHandler = require('../../CallHandler');
const Resend = require("../../Resend");
const dis = require("../../dispatcher"); const dis = require("../../dispatcher");
const Tinter = require("../../Tinter"); const Tinter = require("../../Tinter");
const rate_limited_func = require('../../ratelimitedfunc'); const rate_limited_func = require('../../ratelimitedfunc');
const ObjectUtils = require('../../ObjectUtils'); const ObjectUtils = require('../../ObjectUtils');
const Rooms = require('../../Rooms'); const Rooms = require('../../Rooms');
import KeyCode from '../../KeyCode'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import UserProvider from '../../autocomplete/UserProvider';
import RoomViewStore from '../../stores/RoomViewStore'; import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import SettingsStore from "../../settings/SettingsStore";
const DEBUG = false; const DEBUG = false;
let debuglog = function() {}; let debuglog = function() {};
@ -112,11 +108,12 @@ module.exports = React.createClass({
draggingFile: false, draggingFile: false,
searching: false, searching: false,
searchResults: null, searchResults: null,
unsentMessageError: '',
callState: null, callState: null,
guestsCanJoin: false, guestsCanJoin: false,
canPeek: false, canPeek: false,
showApps: false, showApps: false,
isAlone: false,
isPeeking: false,
// error object, as from the matrix client/server API // error object, as from the matrix client/server API
// If we failed to load information about the room, // If we failed to load information about the room,
@ -149,8 +146,6 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership); MatrixClientPeg.get().on("RoomMember.membership", this.onRoomMemberMembership);
MatrixClientPeg.get().on("accountData", this.onAccountData); MatrixClientPeg.get().on("accountData", this.onAccountData);
this._syncedSettings = UserSettingsStore.getSyncedSettings();
// 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);
@ -204,7 +199,6 @@ module.exports = React.createClass({
if (initial) { if (initial) {
newState.room = MatrixClientPeg.get().getRoom(newState.roomId); newState.room = MatrixClientPeg.get().getRoom(newState.roomId);
if (newState.room) { if (newState.room) {
newState.unsentMessageError = this._getUnsentMessageError(newState.room);
newState.showApps = this._shouldShowApps(newState.room); newState.showApps = this._shouldShowApps(newState.room);
this._onRoomLoaded(newState.room); this._onRoomLoaded(newState.room);
} }
@ -266,6 +260,7 @@ module.exports = React.createClass({
console.log("Attempting to peek into room %s", roomId); console.log("Attempting to peek into room %s", roomId);
this.setState({ this.setState({
peekLoading: true, peekLoading: true,
isPeeking: true, // this will change to false if peeking fails
}); });
MatrixClientPeg.get().peekInRoom(roomId).then((room) => { MatrixClientPeg.get().peekInRoom(roomId).then((room) => {
this.setState({ this.setState({
@ -274,6 +269,11 @@ module.exports = React.createClass({
}); });
this._onRoomLoaded(room); this._onRoomLoaded(room);
}, (err) => { }, (err) => {
// Stop peeking if anything went wrong
this.setState({
isPeeking: false,
});
// This won't necessarily be a MatrixError, but we duck-type // This won't necessarily be a MatrixError, but we duck-type
// here and say if it's got an 'errcode' key with the right value, // here and say if it's got an 'errcode' key with the right value,
// it means we can't peek. // it means we can't peek.
@ -290,12 +290,22 @@ module.exports = React.createClass({
} else if (room) { } else if (room) {
// Stop peeking because we have joined this room previously // Stop peeking because we have joined this room previously
MatrixClientPeg.get().stopPeeking(); MatrixClientPeg.get().stopPeeking();
this.setState({isPeeking: false});
} }
}, },
_shouldShowApps: function(room) { _shouldShowApps: function(room) {
if (!BROWSER_SUPPORTS_SANDBOX) return false; if (!BROWSER_SUPPORTS_SANDBOX) return false;
// Check if user has previously chosen to hide the app drawer for this
// room. If so, do not show apps
const hideWidgetDrawer = localStorage.getItem(
room.roomId + "_hide_widget_drawer");
if (hideWidgetDrawer === "true") {
return false;
}
const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets'); const appsStateEvents = room.currentState.getStateEvents('im.vector.modular.widgets');
// any valid widget = show apps // any valid widget = show apps
for (let i = 0; i < appsStateEvents.length; i++) { for (let i = 0; i < appsStateEvents.length; i++) {
@ -419,13 +429,7 @@ module.exports = React.createClass({
onKeyDown: function(ev) { onKeyDown: function(ev) {
let handled = false; let handled = false;
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
let ctrlCmdOnly;
if (isMac) {
ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey;
} else {
ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
}
switch (ev.keyCode) { switch (ev.keyCode) {
case KeyCode.KEY_D: case KeyCode.KEY_D:
@ -453,10 +457,7 @@ module.exports = React.createClass({
switch (payload.action) { switch (payload.action) {
case 'message_send_failed': case 'message_send_failed':
case 'message_sent': case 'message_sent':
case 'message_send_cancelled': this._checkIfAlone(this.state.room);
this.setState({
unsentMessageError: this._getUnsentMessageError(this.state.room),
});
break; break;
case 'notifier_enabled': case 'notifier_enabled':
case 'upload_failed': case 'upload_failed':
@ -524,18 +525,12 @@ module.exports = React.createClass({
// update unread count when scrolled up // update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
// no change // no change
} else if (!shouldHideEvent(ev, this._syncedSettings)) { } else if (!shouldHideEvent(ev)) {
this.setState((state, props) => { this.setState((state, props) => {
return {numUnreadMessages: state.numUnreadMessages + 1}; return {numUnreadMessages: state.numUnreadMessages + 1};
}); });
} }
} }
// update the tab complete list as it depends on who most recently spoke,
// and that has probably just changed
if (ev.sender) {
UserProvider.getInstance().onUserSpoke(ev.sender);
}
}, },
onRoomName: function(room) { onRoomName: function(room) {
@ -557,7 +552,6 @@ module.exports = React.createClass({
this._warnAboutEncryption(room); this._warnAboutEncryption(room);
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room); this._updatePreviewUrlVisibility(room);
UserProvider.getInstance().setUserListFromRoom(room);
}, },
_warnAboutEncryption: function(room) { _warnAboutEncryption: function(room) {
@ -605,38 +599,8 @@ module.exports = React.createClass({
}, },
_updatePreviewUrlVisibility: function(room) { _updatePreviewUrlVisibility: function(room) {
// console.log("_updatePreviewUrlVisibility");
// check our per-room overrides
const roomPreviewUrls = room.getAccountData("org.matrix.room.preview_urls");
if (roomPreviewUrls && roomPreviewUrls.getContent().disable !== undefined) {
this.setState({ this.setState({
showUrlPreview: !roomPreviewUrls.getContent().disable, showUrlPreview: SettingsStore.getValue("urlPreviewsEnabled", room.roomId),
});
return;
}
// check our global disable override
const userRoomPreviewUrls = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls");
if (userRoomPreviewUrls && userRoomPreviewUrls.getContent().disable) {
this.setState({
showUrlPreview: false,
});
return;
}
// check the room state event
const roomStatePreviewUrls = room.currentState.getStateEvents('org.matrix.room.preview_urls', '');
if (roomStatePreviewUrls && roomStatePreviewUrls.getContent().disable) {
this.setState({
showUrlPreview: false,
});
return;
}
// otherwise, we assume they're on.
this.setState({
showUrlPreview: true,
}); });
}, },
@ -655,12 +619,7 @@ module.exports = React.createClass({
const room = this.state.room; const room = this.state.room;
if (!room) return; if (!room) return;
const color_scheme_event = room.getAccountData("org.matrix.room.color_scheme"); const color_scheme = SettingsStore.getValue("roomColor", room.room_id);
let color_scheme = {};
if (color_scheme_event) {
color_scheme = color_scheme_event.getContent();
// XXX: we should validate the event
}
console.log("Tinter.tint from updateTint"); console.log("Tinter.tint from updateTint");
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}, },
@ -711,9 +670,6 @@ module.exports = React.createClass({
// refresh the conf call notification state // refresh the conf call notification state
this._updateConfCallNotification(); this._updateConfCallNotification();
// refresh the tab complete list
UserProvider.getInstance().setUserListFromRoom(this.state.room);
// if we are now a member of the room, where we were not before, that // if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking // means we have finished joining a room we were previously peeking
// into. // into.
@ -732,34 +688,18 @@ module.exports = React.createClass({
} }
}, 500), }, 500),
_getUnsentMessageError: function(room) { _checkIfAlone: function(room) {
const unsentMessages = this._getUnsentMessages(room); let warnedAboutLonelyRoom = false;
if (!unsentMessages.length) return ""; if (localStorage) {
warnedAboutLonelyRoom = localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId);
if ( }
unsentMessages.length === 1 && if (warnedAboutLonelyRoom) {
unsentMessages[0].error && if (this.state.isAlone) this.setState({isAlone: false});
unsentMessages[0].error.data && return;
unsentMessages[0].error.data.error &&
unsentMessages[0].error.name !== "UnknownDeviceError"
) {
return unsentMessages[0].error.data.error;
} }
for (const event of unsentMessages) { const joinedMembers = room.currentState.getMembers().filter((m) => m.membership === "join" || m.membership === "invite");
if (!event.error || event.error.name !== "UnknownDeviceError") { this.setState({isAlone: joinedMembers.length === 1});
if (unsentMessages.length > 1) return _t("Some of your messages have not been sent.");
return _t("Your message was not sent.");
}
}
return _t("Message not sent due to unknown devices being present");
},
_getUnsentMessages: function(room) {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
return ev.status === Matrix.EventStatus.NOT_SENT;
});
}, },
_updateConfCallNotification: function() { _updateConfCallNotification: function() {
@ -806,12 +746,20 @@ module.exports = React.createClass({
} }
}, },
onResendAllClick: function() { onInviteButtonClick: function() {
Resend.resendUnsentEvents(this.state.room); // call AddressPickerDialog
dis.dispatch({
action: 'view_invite',
roomId: this.state.room.roomId,
});
this.setState({isAlone: false}); // there's a good chance they'll invite someone
}, },
onCancelAllClick: function() { onStopAloneWarningClick: function() {
Resend.cancelUnsentEvents(this.state.room); if (localStorage) {
localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, true);
}
this.setState({isAlone: false});
}, },
onJoinButtonClicked: function(ev) { onJoinButtonClicked: function(ev) {
@ -906,9 +854,13 @@ module.exports = React.createClass({
ev.dataTransfer.dropEffect = 'none'; ev.dataTransfer.dropEffect = 'none';
const items = ev.dataTransfer.items; const items = [...ev.dataTransfer.items];
if (items.length == 1) { if (items.length >= 1) {
if (items[0].kind == 'file') { const isDraggingFiles = items.every(function(item) {
return item.kind == 'file';
});
if (isDraggingFiles) {
this.setState({ draggingFile: true }); this.setState({ draggingFile: true });
ev.dataTransfer.dropEffect = 'copy'; ev.dataTransfer.dropEffect = 'copy';
} }
@ -919,10 +871,8 @@ module.exports = React.createClass({
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
this.setState({ draggingFile: false }); this.setState({ draggingFile: false });
const files = ev.dataTransfer.files; const files = [...ev.dataTransfer.files];
if (files.length == 1) { files.forEach(this.uploadFile);
this.uploadFile(files[0]);
}
}, },
onDragLeaveOrEnd: function(ev) { onDragLeaveOrEnd: function(ev) {
@ -941,11 +891,7 @@ module.exports = React.createClass({
file, this.state.room.roomId, MatrixClientPeg.get(), file, this.state.room.roomId, MatrixClientPeg.get(),
).done(undefined, (error) => { ).done(undefined, (error) => {
if (error.name === "UnknownDeviceError") { if (error.name === "UnknownDeviceError") {
dis.dispatch({ // Let the staus bar handle this
action: 'unknown_device_error',
err: error,
room: this.state.room,
});
return; return;
} }
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -1110,7 +1056,7 @@ module.exports = React.createClass({
} }
if (this.state.searchScope === 'All') { if (this.state.searchScope === 'All') {
if(roomId != lastRoomId) { if (roomId != lastRoomId) {
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
// XXX: if we've left the room, we might not know about // XXX: if we've left the room, we might not know about
@ -1137,6 +1083,10 @@ module.exports = React.createClass({
return ret; return ret;
}, },
onPinnedClick: function() {
this.setState({showingPinned: !this.state.showingPinned, searching: false});
},
onSettingsClick: function() { onSettingsClick: function() {
this.showSettings(true); this.showSettings(true);
}, },
@ -1256,7 +1206,7 @@ module.exports = React.createClass({
}, },
onSearchClick: function() { onSearchClick: function() {
this.setState({ searching: true }); this.setState({ searching: true, showingPinned: false });
}, },
onCancelSearchClick: function() { onCancelSearchClick: function() {
@ -1417,13 +1367,13 @@ module.exports = React.createClass({
*/ */
handleScrollKey: function(ev) { handleScrollKey: function(ev) {
let panel; let panel;
if(this.refs.searchResultsPanel) { if (this.refs.searchResultsPanel) {
panel = this.refs.searchResultsPanel; panel = this.refs.searchResultsPanel;
} else if(this.refs.messagePanel) { } else if (this.refs.messagePanel) {
panel = this.refs.messagePanel; panel = this.refs.messagePanel;
} }
if(panel) { if (panel) {
panel.handleScrollKey(ev); panel.handleScrollKey(ev);
} }
}, },
@ -1442,7 +1392,7 @@ module.exports = React.createClass({
// otherwise react calls it with null on each update. // otherwise react calls it with null on each update.
_gatherTimelinePanelRef: function(r) { _gatherTimelinePanelRef: function(r) {
this.refs.messagePanel = r; this.refs.messagePanel = r;
if(r) { if (r) {
console.log("updateTint from RoomView._gatherTimelinePanelRef"); console.log("updateTint from RoomView._gatherTimelinePanelRef");
this.updateTint(); this.updateTint();
} }
@ -1455,6 +1405,7 @@ module.exports = React.createClass({
const RoomSettings = sdk.getComponent("rooms.RoomSettings"); const RoomSettings = sdk.getComponent("rooms.RoomSettings");
const AuxPanel = sdk.getComponent("rooms.AuxPanel"); const AuxPanel = sdk.getComponent("rooms.AuxPanel");
const SearchBar = sdk.getComponent("rooms.SearchBar"); const SearchBar = sdk.getComponent("rooms.SearchBar");
const PinnedEventsPanel = sdk.getComponent("rooms.PinnedEventsPanel");
const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); const ScrollPanel = sdk.getComponent("structures.ScrollPanel");
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
@ -1571,13 +1522,12 @@ module.exports = React.createClass({
isStatusAreaExpanded = this.state.statusBarVisible; isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
numUnsentMessages={this._getUnsentMessages().length}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
unsentMessageError={this.state.unsentMessageError}
atEndOfLiveTimeline={this.state.atEndOfLiveTimeline} atEndOfLiveTimeline={this.state.atEndOfLiveTimeline}
sentMessageAndIsAlone={this.state.isAlone}
hasActiveCall={inCall} hasActiveCall={inCall}
onResendAllClick={this.onResendAllClick} onInviteClick={this.onInviteButtonClick}
onCancelAllClick={this.onCancelAllClick} onStopWarningClick={this.onStopAloneWarningClick}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
onResize={this.onChildResize} onResize={this.onChildResize}
onVisible={this.onStatusBarVisible} onVisible={this.onStatusBarVisible}
@ -1597,6 +1547,9 @@ 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 (this.state.showingPinned) {
hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (!myMember || myMember.membership !== "join") { } else if (!myMember || myMember.membership !== "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.
@ -1647,7 +1600,7 @@ module.exports = React.createClass({
onResize={this.onChildResize} onResize={this.onChildResize}
uploadFile={this.uploadFile} uploadFile={this.uploadFile}
callState={this.state.callState} callState={this.state.callState}
opacity={this.props.opacity} disabled={this.props.disabled}
showApps={this.state.showApps} showApps={this.state.showApps}
/>; />;
} }
@ -1708,7 +1661,6 @@ module.exports = React.createClass({
className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel" className="mx_RoomView_messagePanel mx_RoomView_searchResultsPanel"
onFillRequest={this.onSearchResultsFillRequest} onFillRequest={this.onSearchResultsFillRequest}
onResize={this.onSearchResultsResize} onResize={this.onSearchResultsResize}
style={{ opacity: this.props.opacity }}
> >
<li className={scrollheader_classes}></li> <li className={scrollheader_classes}></li>
{ this.getSearchResultTiles() } { this.getSearchResultTiles() }
@ -1729,9 +1681,9 @@ module.exports = React.createClass({
const messagePanel = ( const messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef} <TimelinePanel ref={this._gatherTimelinePanelRef}
timelineSet={this.state.room.getUnfilteredTimelineSet()} timelineSet={this.state.room.getUnfilteredTimelineSet()}
showReadReceipts={!UserSettingsStore.getSyncedSetting('hideReadReceipts', false)} showReadReceipts={!SettingsStore.getValue('hideReadReceipts')}
manageReadReceipts={true} manageReadReceipts={!this.state.isPeeking}
manageReadMarkers={true} manageReadMarkers={!this.state.isPeeking}
hidden={hideMessagePanel} hidden={hideMessagePanel}
highlightedEventId={highlightedEventId} highlightedEventId={highlightedEventId}
eventId={this.state.initialEventId} eventId={this.state.initialEventId}
@ -1739,7 +1691,6 @@ module.exports = React.createClass({
onScroll={this.onMessageListScroll} onScroll={this.onMessageListScroll}
onReadMarkerUpdated={this._updateTopUnreadMessagesBar} onReadMarkerUpdated={this._updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview} showUrlPreview = {this.state.showUrlPreview}
opacity={this.props.opacity}
className="mx_RoomView_messagePanel" className="mx_RoomView_messagePanel"
/>); />);
@ -1747,7 +1698,7 @@ module.exports = React.createClass({
if (this.state.showTopUnreadMessagesBar) { if (this.state.showTopUnreadMessagesBar) {
const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar'); const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar');
topUnreadMessagesBar = ( topUnreadMessagesBar = (
<div className="mx_RoomView_topUnreadMessagesBar mx_fadable" style={{ opacity: this.props.opacity }}> <div className="mx_RoomView_topUnreadMessagesBar">
<TopUnreadMessagesBar <TopUnreadMessagesBar
onScrollUpClick={this.jumpToReadMarker} onScrollUpClick={this.jumpToReadMarker}
onCloseClick={this.forgetReadMarker} onCloseClick={this.forgetReadMarker}
@ -1755,10 +1706,19 @@ module.exports = React.createClass({
</div> </div>
); );
} }
let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable"; const statusBarAreaClass = classNames(
if (isStatusAreaExpanded) { "mx_RoomView_statusArea",
statusBarAreaClass += " mx_RoomView_statusArea_expanded"; {
} "mx_RoomView_statusArea_expanded": isStatusAreaExpanded,
},
);
const fadableSectionClasses = classNames(
"mx_RoomView_body", "mx_fadable",
{
"mx_fadable_faded": this.props.disabled,
},
);
return ( return (
<div className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView"> <div className={"mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "")} ref="roomView">
@ -1770,16 +1730,18 @@ module.exports = React.createClass({
collapsedRhs={this.props.collapsedRhs} collapsedRhs={this.props.collapsedRhs}
onSearchClick={this.onSearchClick} onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick} onSettingsClick={this.onSettingsClick}
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={(myMember && myMember.membership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMember && myMember.membership === "join") ? this.onLeaveClick : null} onLeaveClick={(myMember && myMember.membership === "join") ? this.onLeaveClick : null}
/> />
{ auxPanel } { auxPanel }
<div className={fadableSectionClasses}>
{ topUnreadMessagesBar } { topUnreadMessagesBar }
{ messagePanel } { messagePanel }
{ searchResultsPanel } { searchResultsPanel }
<div className={statusBarAreaClass} style={{opacity: this.props.opacity}}> <div className={statusBarAreaClass}>
<div className="mx_RoomView_statusAreaBox"> <div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div> <div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar } { statusBar }
@ -1787,6 +1749,7 @@ module.exports = React.createClass({
</div> </div>
{ messageComposer } { messageComposer }
</div> </div>
</div>
); );
}, },
}); });

View file

@ -18,7 +18,7 @@ const React = require("react");
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
const GeminiScrollbar = require('react-gemini-scrollbar'); const GeminiScrollbar = require('react-gemini-scrollbar');
import Promise from 'bluebird'; import Promise from 'bluebird';
const KeyCode = require('../../KeyCode'); import { KeyCode } from '../../Keyboard';
const DEBUG_SCROLL = false; const DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true; // var DEBUG_SCROLL = true;
@ -148,6 +148,7 @@ module.exports = React.createClass({
onFillRequest: function(backwards) { return Promise.resolve(false); }, onFillRequest: function(backwards) { return Promise.resolve(false); },
onUnfillRequest: function(backwards, scrollToken) {}, onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {}, onScroll: function() {},
onResize: function() {},
}; };
}, },
@ -572,7 +573,7 @@ module.exports = React.createClass({
debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" + debuglog("ScrollPanel: scrolling to token '" + scrollToken + "'+" +
pixelOffset + " (delta: "+scrollDelta+")"); pixelOffset + " (delta: "+scrollDelta+")");
if(scrollDelta != 0) { if (scrollDelta != 0) {
this._setScrollTop(scrollNode.scrollTop + scrollDelta); this._setScrollTop(scrollNode.scrollTop + scrollDelta);
} }
}, },

View file

@ -0,0 +1,116 @@
/*
Copyright 2017 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import FilterStore from '../../stores/FilterStore';
import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions';
import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index';
import dis from '../../dispatcher';
const TagPanel = React.createClass({
displayName: 'TagPanel',
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getInitialState() {
return {
orderedTags: [],
selectedTags: [],
};
},
componentWillMount: function() {
this.unmounted = false;
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this._filterStoreToken = FilterStore.addListener(() => {
if (this.unmounted) {
return;
}
this.setState({
selectedTags: FilterStore.getSelectedTags(),
});
});
this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) {
return;
}
this.setState({
orderedTags: TagOrderStore.getOrderedTags() || [],
});
});
// This could be done by anything with a matrix client
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
},
componentWillUnmount() {
this.unmounted = true;
this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
if (this._filterStoreToken) {
this._filterStoreToken.remove();
}
},
_onGroupMyMembership() {
if (this.unmounted) return;
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
},
onClick() {
dis.dispatch({action: 'deselect_tags'});
},
onCreateGroupClick(ev) {
ev.stopPropagation();
dis.dispatch({action: 'view_create_group'});
},
onTagTileEndDrag() {
dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient));
},
render() {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile
key={tag + '_' + index}
tag={tag}
selected={this.state.selectedTags.includes(tag)}
onEndDrag={this.onTagTileEndDrag}
/>;
});
return <div className="mx_TagPanel" onClick={this.onClick}>
<div className="mx_TagPanel_tagTileContainer">
{ tags }
</div>
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}>
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" />
</AccessibleButton>
</div>;
},
});
export default TagPanel;

View file

@ -15,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import SettingsStore from "../../settings/SettingsStore";
const React = require('react'); const React = require('react');
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
import Promise from 'bluebird'; import Promise from 'bluebird';
@ -29,8 +31,7 @@ const dis = require("../../dispatcher");
const ObjectUtils = require('../../ObjectUtils'); const ObjectUtils = require('../../ObjectUtils');
const Modal = require("../../Modal"); const Modal = require("../../Modal");
const UserActivity = require("../../UserActivity"); const UserActivity = require("../../UserActivity");
const KeyCode = require('../../KeyCode'); import { KeyCode } from '../../Keyboard';
import UserSettingsStore from '../../UserSettingsStore';
const PAGINATE_SIZE = 20; const PAGINATE_SIZE = 20;
const INITIAL_SIZE = 20; const INITIAL_SIZE = 20;
@ -89,9 +90,6 @@ var TimelinePanel = React.createClass({
// callback which is called when the read-up-to mark is updated. // callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: React.PropTypes.func, onReadMarkerUpdated: React.PropTypes.func,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
// maximum number of events to show in a timeline // maximum number of events to show in a timeline
timelineCap: React.PropTypes.number, timelineCap: React.PropTypes.number,
@ -132,8 +130,6 @@ var TimelinePanel = React.createClass({
} }
} }
const syncedSettings = UserSettingsStore.getSyncedSettings();
return { return {
events: [], events: [],
timelineLoading: true, // track whether our room timeline is loading timelineLoading: true, // track whether our room timeline is loading
@ -178,10 +174,10 @@ var TimelinePanel = React.createClass({
clientSyncState: MatrixClientPeg.get().getSyncState(), clientSyncState: MatrixClientPeg.get().getSyncState(),
// should the event tiles have twelve hour times // should the event tiles have twelve hour times
isTwelveHour: syncedSettings.showTwelveHourTimestamps, isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"),
// always show timestamps on event tiles? // always show timestamps on event tiles?
alwaysShowTimestamps: syncedSettings.alwaysShowTimestamps, alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"),
}; };
}, },
@ -314,7 +310,7 @@ var TimelinePanel = React.createClass({
return Promise.resolve(false); return Promise.resolve(false);
} }
if(!this._timelineWindow.canPaginate(dir)) { if (!this._timelineWindow.canPaginate(dir)) {
debuglog("TimelinePanel: can't", dir, "paginate any further"); debuglog("TimelinePanel: can't", dir, "paginate any further");
this.setState({[canPaginateKey]: false}); this.setState({[canPaginateKey]: false});
return Promise.resolve(false); return Promise.resolve(false);
@ -444,7 +440,7 @@ var TimelinePanel = React.createClass({
var callback = null; var callback = null;
if (sender != myUserId && !UserActivity.userCurrentlyActive()) { if (sender != myUserId && !UserActivity.userCurrentlyActive()) {
updatedState.readMarkerVisible = true; updatedState.readMarkerVisible = true;
} else if(lastEv && this.getReadMarkerPosition() === 0) { } else if (lastEv && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM // we know we're stuckAtBottom, so we can advance the RM
// immediately, to save a later render cycle // immediately, to save a later render cycle
@ -661,7 +657,7 @@ var TimelinePanel = React.createClass({
// the read-marker should become invisible, so that if the user scrolls // the read-marker should become invisible, so that if the user scrolls
// down, they don't see it. // down, they don't see it.
if(this.state.readMarkerVisible) { if (this.state.readMarkerVisible) {
this.setState({ this.setState({
readMarkerVisible: false, readMarkerVisible: false,
}); });
@ -1157,7 +1153,6 @@ var TimelinePanel = React.createClass({
onScroll={this.onMessageListScroll} onScroll={this.onMessageListScroll}
onFillRequest={this.onMessageListFillRequest} onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest} onUnfillRequest={this.onMessageListUnfillRequest}
opacity={this.props.opacity}
isTwelveHour={this.state.isTwelveHour} isTwelveHour={this.state.isTwelveHour}
alwaysShowTimestamps={this.state.alwaysShowTimestamps} alwaysShowTimestamps={this.state.alwaysShowTimestamps}
className={this.props.className} className={this.props.className}

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 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,6 +15,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
const sdk = require('../../index'); const sdk = require('../../index');
@ -55,125 +58,69 @@ const gHVersionLabel = function(repo, token='') {
return <a target="_blank" rel="noopener" href={url}>{ token }</a>; return <a target="_blank" rel="noopener" href={url}>{ token }</a>;
}; };
// Enumerate some simple 'flip a bit' UI settings (if any). // Enumerate some simple 'flip a bit' UI settings (if any). The strings provided here
// 'id' gives the key name in the im.vector.web.settings account data event // must be settings defined in SettingsStore.
// 'label' is how we describe it in the UI. const SIMPLE_SETTINGS = [
// Warning: Each "label" string below must be added to i18n/strings/en_EN.json, { id: "urlPreviewsEnabled" },
// since they will be translated when rendered. { id: "autoplayGifsAndVideos" },
const SETTINGS_LABELS = [ { id: "hideReadReceipts" },
{ { id: "dontSendTypingNotifications" },
id: 'autoplayGifsAndVideos', { id: "alwaysShowTimestamps" },
label: _td('Autoplay GIFs and videos'), { id: "showTwelveHourTimestamps" },
}, { id: "hideJoinLeaves" },
{ { id: "hideAvatarChanges" },
id: 'hideReadReceipts', { id: "hideDisplaynameChanges" },
label: _td('Hide read receipts'), { id: "useCompactLayout" },
}, { id: "hideRedactions" },
{ { id: "enableSyntaxHighlightLanguageDetection" },
id: 'dontSendTypingNotifications', { id: "MessageComposerInput.autoReplaceEmoji" },
label: _td("Don't send typing notifications"), { id: "MessageComposerInput.dontSuggestEmoji" },
}, { id: "Pill.shouldHidePillAvatar" },
{ { id: "TextualBody.disableBigEmoji" },
id: 'alwaysShowTimestamps', { id: "VideoView.flipVideoHorizontally" },
label: _td('Always show message timestamps'),
},
{
id: 'showTwelveHourTimestamps',
label: _td('Show timestamps in 12 hour format (e.g. 2:30pm)'),
},
{
id: 'hideJoinLeaves',
label: _td('Hide join/leave messages (invites/kicks/bans unaffected)'),
},
{
id: 'hideAvatarDisplaynameChanges',
label: _td('Hide avatar and display name changes'),
},
{
id: 'useCompactLayout',
label: _td('Use compact timeline layout'),
},
{
id: 'hideRedactions',
label: _td('Hide removed messages'),
},
{
id: 'enableSyntaxHighlightLanguageDetection',
label: _td('Enable automatic language detection for syntax highlighting'),
},
{
id: 'MessageComposerInput.autoReplaceEmoji',
label: _td('Automatically replace plain text Emoji'),
},
{
id: 'MessageComposerInput.dontSuggestEmoji',
label: _td('Disable Emoji suggestions while typing'),
},
{
id: 'Pill.shouldHidePillAvatar',
label: _td('Hide avatars in user and room mentions'),
},
/*
{
id: 'useFixedWidthFont',
label: 'Use fixed width font',
},
*/
]; ];
const ANALYTICS_SETTINGS_LABELS = [ // These settings must be defined in SettingsStore
const ANALYTICS_SETTINGS = [
{ {
id: 'analyticsOptOut', id: 'analyticsOptOut',
label: _td('Opt out of analytics'),
fn: function(checked) { fn: function(checked) {
Analytics[checked ? 'disable' : 'enable'](); Analytics[checked ? 'disable' : 'enable']();
}, },
}, },
]; ];
const WEBRTC_SETTINGS_LABELS = [ // These settings must be defined in SettingsStore
const WEBRTC_SETTINGS = [
{ {
id: 'webRtcForceTURN', id: 'webRtcForceTURN',
label: _td('Disable Peer-to-Peer for 1:1 calls'), fn: (val) => {
MatrixClientPeg.get().setForceTURN(val);
},
}, },
]; ];
// Warning: Each "label" string below must be added to i18n/strings/en_EN.json, // These settings must be defined in SettingsStore
// since they will be translated when rendered. const CRYPTO_SETTINGS = [
const CRYPTO_SETTINGS_LABELS = [
{ {
id: 'blacklistUnverifiedDevices', id: 'blacklistUnverifiedDevices',
label: _td('Never send encrypted messages to unverified devices from this device'),
fn: function(checked) { fn: function(checked) {
MatrixClientPeg.get().setGlobalBlacklistUnverifiedDevices(checked); MatrixClientPeg.get().setGlobalBlacklistUnverifiedDevices(checked);
}, },
}, },
// XXX: this is here for documentation; the actual setting is managed via RoomSettings
// {
// id: 'blacklistUnverifiedDevicesPerRoom'
// label: 'Never send encrypted messages to unverified devices in this room',
// }
]; ];
// Enumerate the available themes, with a nice human text label. // Enumerate the available themes, with a nice human text label.
// 'id' gives the key name in the im.vector.web.settings account data event
// 'value' is the value for that key in the event
// 'label' is how we describe it in the UI. // 'label' is how we describe it in the UI.
// 'value' is the value for the theme setting
// //
// XXX: Ideally we would have a theme manifest or something and they'd be nicely // XXX: Ideally we would have a theme manifest or something and they'd be nicely
// packaged up in a single directory, and/or located at the application layer. // packaged up in a single directory, and/or located at the application layer.
// But for now for expedience we just hardcode them here. // But for now for expedience we just hardcode them here.
const THEMES = [ const THEMES = [
{ { label: _td('Light theme'), value: 'light' },
id: 'theme', { label: _td('Dark theme'), value: 'dark' },
label: _td('Light theme'), { label: _td('Status.im theme'), value: 'status' },
value: 'light',
},
{
id: 'theme',
label: _td('Dark theme'),
value: 'dark',
},
]; ];
const IgnoredUser = React.createClass({ const IgnoredUser = React.createClass({
@ -195,7 +142,7 @@ const IgnoredUser = React.createClass({
render: function() { render: function() {
return ( return (
<li> <li>
<AccessibleButton onClick={this._onUnignoreClick} className="mx_UserSettings_button mx_UserSettings_buttonSmall"> <AccessibleButton onClick={this._onUnignoreClick} className="mx_textButton">
{ _t("Unignore") } { _t("Unignore") }
</AccessibleButton> </AccessibleButton>
{ this.props.userId } { this.props.userId }
@ -212,9 +159,6 @@ module.exports = React.createClass({
// The brand string given when creating email pushers // The brand string given when creating email pushers
brand: React.PropTypes.string, brand: React.PropTypes.string,
// True to show the 'labs' section of experimental features
enableLabs: React.PropTypes.bool,
// The base URL to use in the referral link. Defaults to window.location.origin. // The base URL to use in the referral link. Defaults to window.location.origin.
referralBaseUrl: React.PropTypes.string, referralBaseUrl: React.PropTypes.string,
@ -226,7 +170,6 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
onClose: function() {}, onClose: function() {},
enableLabs: true,
}; };
}, },
@ -270,20 +213,12 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("RoomMember.membership", this._onInviteStateChange); MatrixClientPeg.get().on("RoomMember.membership", this._onInviteStateChange);
dis.dispatch({ dis.dispatch({
action: 'ui_opacity', action: 'panel_disable',
sideOpacity: 0.3, sideDisabled: true,
middleOpacity: 0.3, middleDisabled: true,
}); });
this._refreshFromServer(); this._refreshFromServer();
const syncedSettings = UserSettingsStore.getSyncedSettings();
if (!syncedSettings.theme) {
syncedSettings.theme = 'light';
}
this._syncedSettings = syncedSettings;
this._localSettings = UserSettingsStore.getLocalSettings();
if (PlatformPeg.get().isElectron()) { if (PlatformPeg.get().isElectron()) {
const {ipcRenderer} = require('electron'); const {ipcRenderer} = require('electron');
@ -310,9 +245,9 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
this._unmounted = true; this._unmounted = true;
dis.dispatch({ dis.dispatch({
action: 'ui_opacity', action: 'panel_disable',
sideOpacity: 1.0, sideDisabled: false,
middleOpacity: 1.0, middleDisabled: false,
}); });
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -354,8 +289,8 @@ module.exports = React.createClass({
if (this._unmounted) return; if (this._unmounted) return;
this.setState({ this.setState({
mediaDevices, mediaDevices,
activeAudioInput: this._localSettings['webrtc_audioinput'], activeAudioInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_audioinput'),
activeVideoInput: this._localSettings['webrtc_videoinput'], activeVideoInput: SettingsStore.getValueAt(SettingLevel.DEVICE, 'webrtc_videoinput'),
}); });
}); });
}, },
@ -426,6 +361,11 @@ module.exports = React.createClass({
}); });
}, },
onAvatarRemoveClick: function() {
MatrixClientPeg.get().setAvatarUrl(null);
this.setState({avatarUrl: null}); // the avatar update will complete async for us
},
onLogoutClicked: function(ev) { onLogoutClicked: function(ev) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Logout E2E Export', '', QuestionDialog, { Modal.createTrackedDialog('Logout E2E Export', '', QuestionDialog, {
@ -435,7 +375,7 @@ module.exports = React.createClass({
{ _t("For security, logging out will delete any end-to-end " + { _t("For security, logging out will delete any end-to-end " +
"encryption keys from this browser. If you want to be able " + "encryption keys from this browser. If you want to be able " +
"to decrypt your conversation history from future Riot sessions, " + "to decrypt your conversation history from future Riot sessions, " +
"please export your room keys for safe-keeping.") }. "please export your room keys for safe-keeping.") }
</div>, </div>,
button: _t("Sign out"), button: _t("Sign out"),
extraButtons: [ extraButtons: [
@ -482,10 +422,6 @@ module.exports = React.createClass({
dis.dispatch({action: 'password_changed'}); dis.dispatch({action: 'password_changed'});
}, },
onEnableNotificationsChange: function(event) {
UserSettingsStore.setEnableNotifications(event.target.checked);
},
_onAddEmailEditFinished: function(value, shouldSubmit) { _onAddEmailEditFinished: function(value, shouldSubmit) {
if (!shouldSubmit) return; if (!shouldSubmit) return;
this._addEmail(); this._addEmail();
@ -659,6 +595,11 @@ module.exports = React.createClass({
}); });
}, },
_renderGroupSettings: function() {
const GroupUserSettings = sdk.getComponent('groups.GroupUserSettings');
return <GroupUserSettings />;
},
_renderReferral: function() { _renderReferral: function() {
const teamToken = this.props.teamToken; const teamToken = this.props.teamToken;
if (!teamToken) { if (!teamToken) {
@ -681,8 +622,8 @@ module.exports = React.createClass({
}, },
onLanguageChange: function(newLang) { onLanguageChange: function(newLang) {
if(this.state.language !== newLang) { if (this.state.language !== newLang) {
UserSettingsStore.setLocalSetting('language', newLang); SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
this.setState({ this.setState({
language: newLang, language: newLang,
}); });
@ -705,14 +646,13 @@ module.exports = React.createClass({
// 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 = (e) =>
UserSettingsStore.setLocalSetting('autocompleteDelay', + e.target.value); SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value);
return ( return (
<div> <div>
<h3>{ _t("User Interface") }</h3> <h3>{ _t("User Interface") }</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
{ this._renderUrlPreviewSelector() } { SIMPLE_SETTINGS.map( this._renderAccountSetting ) }
{ SETTINGS_LABELS.map( this._renderSyncedSetting ) } { THEMES.map( this._renderThemeOption ) }
{ THEMES.map( this._renderThemeSelector ) }
<table> <table>
<tbody> <tbody>
<tr> <tr>
@ -720,7 +660,7 @@ module.exports = React.createClass({
<td> <td>
<input <input
type="number" type="number"
defaultValue={UserSettingsStore.getLocalSetting('autocompleteDelay', 200)} defaultValue={SettingsStore.getValueAt(SettingLevel.DEVICE, "autocompleteDelay")}
onChange={onChange} onChange={onChange}
/> />
</td> </td>
@ -733,69 +673,31 @@ module.exports = React.createClass({
); );
}, },
_renderUrlPreviewSelector: function() { _renderAccountSetting: function(setting) {
return <div className="mx_UserSettings_toggle"> const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
<input id="urlPreviewsDisabled" return (
type="checkbox" <div className="mx_UserSettings_toggle" key={setting.id}>
defaultChecked={UserSettingsStore.getUrlPreviewsDisabled()} <SettingsFlag name={setting.id}
onChange={this._onPreviewsDisabledChanged} label={setting.label}
/> level={SettingLevel.ACCOUNT}
<label htmlFor="urlPreviewsDisabled"> onChange={setting.fn} />
{ _t("Disable inline URL previews by default") } </div>
</label> );
</div>;
}, },
_onPreviewsDisabledChanged: function(e) { _renderThemeOption: function(setting) {
UserSettingsStore.setUrlPreviewsDisabled(e.target.checked); const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
}, const onChange = (v) => dis.dispatch({action: 'set_theme', value: setting.value});
return (
_renderSyncedSetting: function(setting) { <div className="mx_UserSettings_toggle" key={setting.id + '_' + setting.value}>
// TODO: this ought to be a separate component so that we don't need <SettingsFlag name="theme"
// to rebind the onChange each time we render label={setting.label}
level={SettingLevel.ACCOUNT}
const onChange = (e) => {
UserSettingsStore.setSyncedSetting(setting.id, e.target.checked);
if (setting.fn) setting.fn(e.target.checked);
};
return <div className="mx_UserSettings_toggle" key={setting.id}>
<input id={setting.id}
type="checkbox"
defaultChecked={this._syncedSettings[setting.id]}
onChange={onChange} onChange={onChange}
/> group="theme"
<label htmlFor={setting.id}> value={setting.value} />
{ _t(setting.label) } </div>
</label> );
</div>;
},
_renderThemeSelector: function(setting) {
// TODO: this ought to be a separate component so that we don't need
// to rebind the onChange each time we render
const onChange = (e) => {
if (e.target.checked) {
this._syncedSettings[setting.id] = setting.value;
UserSettingsStore.setSyncedSetting(setting.id, setting.value);
}
dis.dispatch({
action: 'set_theme',
value: setting.value,
});
};
return <div className="mx_UserSettings_toggle" key={setting.id + "_" + setting.value}>
<input id={setting.id + "_" + setting.value}
type="radio"
name={setting.id}
value={setting.value}
checked={this._syncedSettings[setting.id] === setting.value}
onChange={onChange}
/>
<label htmlFor={setting.id + "_" + setting.value}>
{ _t(setting.label) }
</label>
</div>;
}, },
_renderCryptoInfo: function() { _renderCryptoInfo: function() {
@ -837,7 +739,7 @@ module.exports = React.createClass({
{ importExportButtons } { importExportButtons }
</div> </div>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
{ CRYPTO_SETTINGS_LABELS.map( this._renderLocalSetting ) } { CRYPTO_SETTINGS.map( this._renderDeviceSetting ) }
</div> </div>
</div> </div>
); );
@ -863,24 +765,16 @@ module.exports = React.createClass({
} else return (<div />); } else return (<div />);
}, },
_renderLocalSetting: function(setting) { _renderDeviceSetting: function(setting) {
// TODO: this ought to be a separate component so that we don't need const SettingsFlag = sdk.getComponent("elements.SettingsFlag");
// to rebind the onChange each time we render return (
const onChange = (e) => { <div className="mx_UserSettings_toggle" key={setting.id}>
UserSettingsStore.setLocalSetting(setting.id, e.target.checked); <SettingsFlag name={setting.id}
if (setting.fn) setting.fn(e.target.checked); label={setting.label}
}; level={SettingLevel.DEVICE}
onChange={setting.fn} />
return <div className="mx_UserSettings_toggle" key={setting.id}> </div>
<input id={setting.id} );
type="checkbox"
defaultChecked={this._localSettings[setting.id]}
onChange={onChange}
/>
<label htmlFor={setting.id}>
{ _t(setting.label) }
</label>
</div>;
}, },
_renderDevicesPanel: function() { _renderDevicesPanel: function() {
@ -917,40 +811,31 @@ module.exports = React.createClass({
<h3>{ _t('Analytics') }</h3> <h3>{ _t('Analytics') }</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
{ _t('Riot collects anonymous analytics to allow us to improve the application.') } { _t('Riot collects anonymous analytics to allow us to improve the application.') }
{ ANALYTICS_SETTINGS_LABELS.map( this._renderLocalSetting ) } { ANALYTICS_SETTINGS.map( this._renderDeviceSetting ) }
</div> </div>
</div>; </div>;
}, },
_renderLabs: function() { _renderLabs: function() {
// default to enabled if undefined
if (this.props.enableLabs === false) return null;
UserSettingsStore.doTranslations();
const features = []; const features = [];
UserSettingsStore.LABS_FEATURES.forEach((feature) => { SettingsStore.getLabsFeatures().forEach((featureId) => {
// This feature has an override and will be set to the default, so do not
// show it here.
if (feature.override) {
return;
}
// 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 = (e) => {
UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked); SettingsStore.setFeatureEnabled(featureId, e.target.checked);
this.forceUpdate(); this.forceUpdate();
}; };
features.push( features.push(
<div key={feature.id} className="mx_UserSettings_toggle"> <div key={featureId} className="mx_UserSettings_toggle">
<input <input
type="checkbox" type="checkbox"
id={feature.id} id={featureId}
name={feature.id} name={featureId}
defaultChecked={UserSettingsStore.isFeatureEnabled(feature.id)} defaultChecked={SettingsStore.isFeatureEnabled(featureId)}
onChange={onChange} onChange={onChange}
/> />
<label htmlFor={feature.id}>{ feature.name }</label> <label htmlFor={featureId}>{ SettingsStore.getDisplayName(featureId) }</label>
</div>); </div>);
}); });
@ -1043,6 +928,8 @@ module.exports = React.createClass({
const settings = this.state.electron_settings; const settings = this.state.electron_settings;
if (!settings) return; if (!settings) return;
// TODO: This should probably be a granular setting, but it only applies to electron
// and ends up being get/set outside of matrix anyways (local system setting).
return <div> return <div>
<h3>{ _t('Desktop specific') }</h3> <h3>{ _t('Desktop specific') }</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
@ -1165,7 +1052,7 @@ module.exports = React.createClass({
return <div> return <div>
<h3>{ _t('VoIP') }</h3> <h3>{ _t('VoIP') }</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
{ WEBRTC_SETTINGS_LABELS.map(this._renderLocalSetting) } { WEBRTC_SETTINGS.map(this._renderDeviceSetting) }
{ this._renderWebRtcDeviceSettings() } { this._renderWebRtcDeviceSettings() }
</div> </div>
</div>; </div>;
@ -1330,7 +1217,14 @@ module.exports = React.createClass({
</div> </div>
<div className="mx_UserSettings_avatarPicker"> <div className="mx_UserSettings_avatarPicker">
<div onClick={this.onAvatarPickerClick}> <div className="mx_UserSettings_avatarPicker_remove" onClick={this.onAvatarRemoveClick}>
<img src="img/cancel.svg"
width="15" height="15"
className="mx_filterFlipColor"
alt={_t("Remove avatar")}
title={_t("Remove avatar")} />
</div>
<div onClick={this.onAvatarPickerClick} className="mx_UserSettings_avatarPicker_imgContainer">
<ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl} <ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl}
showUploadSection={false} className="mx_UserSettings_avatarPicker_img" /> showUploadSection={false} className="mx_UserSettings_avatarPicker_img" />
</div> </div>
@ -1360,6 +1254,8 @@ module.exports = React.createClass({
{ accountJsx } { accountJsx }
</div> </div>
{ this._renderGroupSettings() }
{ this._renderReferral() } { this._renderReferral() }
{ notificationArea } { notificationArea }

View file

@ -17,13 +17,13 @@ limitations under the License.
'use strict'; 'use strict';
const React = require('react'); import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
const sdk = require('../../../index'); import sdk from '../../../index';
const Modal = require("../../../Modal"); import Modal from "../../../Modal";
const MatrixClientPeg = require('../../../MatrixClientPeg'); import MatrixClientPeg from "../../../MatrixClientPeg";
const PasswordReset = require("../../../PasswordReset"); import PasswordReset from "../../../PasswordReset";
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'ForgotPassword', displayName: 'ForgotPassword',
@ -154,6 +154,7 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader"); const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter"); const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig"); const ServerConfig = sdk.getComponent("login.ServerConfig");
@ -165,8 +166,8 @@ module.exports = React.createClass({
resetPasswordJsx = <Spinner />; resetPasswordJsx = <Spinner />;
} else if (this.state.progress === "sent_email") { } else if (this.state.progress === "sent_email") {
resetPasswordJsx = ( resetPasswordJsx = (
<div> <div className="mx_Login_prompt">
{ _t('An email has been sent to') } { this.state.email }. { _t("Once you've followed the link it contains, click below") }. { _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", { emailAddress: this.state.email }) }
<br /> <br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify} <input className="mx_Login_submit" type="button" onClick={this.onVerify}
value={_t('I have verified my email address')} /> value={_t('I have verified my email address')} />
@ -174,7 +175,7 @@ module.exports = React.createClass({
); );
} else if (this.state.progress === "complete") { } else if (this.state.progress === "complete") {
resetPasswordJsx = ( resetPasswordJsx = (
<div> <div className="mx_Login_prompt">
<p>{ _t('Your password has been reset') }.</p> <p>{ _t('Your password has been reset') }.</p>
<p>{ _t('You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device') }.</p> <p>{ _t('You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device') }.</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete} <input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
@ -182,6 +183,20 @@ module.exports = React.createClass({
</div> </div>
); );
} else { } else {
let serverConfigSection;
if (!config.disable_custom_urls) {
serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={0} />
);
}
resetPasswordJsx = ( resetPasswordJsx = (
<div> <div>
<div className="mx_Login_prompt"> <div className="mx_Login_prompt">
@ -209,16 +224,7 @@ module.exports = React.createClass({
<br /> <br />
<input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} /> <input className="mx_Login_submit" type="submit" value={_t('Send Reset Email')} />
</form> </form>
<ServerConfig ref="serverConfig" { serverConfigSection }
withToggleButton={true}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={0} />
<div className="mx_Login_error">
</div>
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#"> <a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
{ _t('Return to login screen') } { _t('Return to login screen') }
</a> </a>
@ -233,12 +239,12 @@ module.exports = React.createClass({
return ( return (
<div className="mx_Login"> <LoginPage>
<div className="mx_Login_box"> <div className="mx_Login_box">
<LoginHeader /> <LoginHeader />
{ resetPasswordJsx } { resetPasswordJsx }
</div> </div>
</div> </LoginPage>
); );
}, },
}); });

View file

@ -18,12 +18,13 @@ limitations under the License.
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import { _t, _tJsx } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler'; import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import Login from '../../../Login'; import Login from '../../../Login';
import UserSettingsStore from '../../../UserSettingsStore';
import PlatformPeg from '../../../PlatformPeg'; import PlatformPeg from '../../../PlatformPeg';
import SdkConfig from '../../../SdkConfig';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
// For validating phone numbers without country codes // For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/; const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/;
@ -76,6 +77,14 @@ module.exports = React.createClass({
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false; this._unmounted = false;
// map from login step type to a function which will render a control
// letting you do that login type
this._stepRendererMap = {
'm.login.password': this._renderPasswordStep,
'm.login.cas': this._renderCasStep,
};
this._initLoginLogic(); this._initLoginLogic();
}, },
@ -95,7 +104,7 @@ module.exports = React.createClass({
).then((data) => { ).then((data) => {
this.props.onLoggedIn(data); this.props.onLoggedIn(data);
}, (error) => { }, (error) => {
if(this._unmounted) { if (this._unmounted) {
return; return;
} }
let errorText; let errorText;
@ -105,7 +114,22 @@ module.exports = React.createClass({
if (error.httpStatus == 400 && usingEmail) { if (error.httpStatus == 400 && usingEmail) {
errorText = _t('This Home Server does not support login using email address.'); errorText = _t('This Home Server does not support login using email address.');
} else if (error.httpStatus === 401 || error.httpStatus === 403) { } else if (error.httpStatus === 401 || error.httpStatus === 403) {
if (SdkConfig.get().disable_custom_urls) {
errorText = (
<div>
<div>{ _t('Incorrect username and/or password.') }</div>
<div className="mx_Login_smallError">
{ _t('Please note you are logging into the %(hs)s server, not matrix.org.',
{
hs: this.props.defaultHsUrl.replace(/^https?:\/\//, ''),
})
}
</div>
</div>
);
} else {
errorText = _t('Incorrect username and/or password.'); errorText = _t('Incorrect username and/or password.');
}
} else { } else {
// other errors, not specific to doing a password login // other errors, not specific to doing a password login
errorText = this._errorTextFromError(error); errorText = this._errorTextFromError(error);
@ -120,7 +144,7 @@ module.exports = React.createClass({
loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403, loginIncorrect: error.httpStatus === 401 || error.httpStatus == 403,
}); });
}).finally(() => { }).finally(() => {
if(this._unmounted) { if (this._unmounted) {
return; return;
} }
this.setState({ this.setState({
@ -217,13 +241,29 @@ module.exports = React.createClass({
loginIncorrect: false, loginIncorrect: false,
}); });
loginLogic.getFlows().then(function(flows) { loginLogic.getFlows().then((flows) => {
// old behaviour was to always use the first flow without presenting // look for a flow where we understand all of the steps.
// options. This works in most cases (we don't have a UI for multiple for (let i = 0; i < flows.length; i++ ) {
// logins so let's skip that for now). if (!this._isSupportedFlow(flows[i])) {
loginLogic.chooseFlow(0); continue;
self.setState({ }
currentFlow: self._getCurrentFlowStep(),
// we just pick the first flow where we support all the
// steps. (we don't have a UI for multiple logins so let's skip
// that for now).
loginLogic.chooseFlow(i);
this.setState({
currentFlow: this._getCurrentFlowStep(),
});
return;
}
// we got to the end of the list without finding a suitable
// flow.
this.setState({
errorText: _t(
"This homeserver doesn't offer any login flows which are " +
"supported by this client.",
),
}); });
}, function(err) { }, function(err) {
self.setState({ self.setState({
@ -237,6 +277,16 @@ module.exports = React.createClass({
}).done(); }).done();
}, },
_isSupportedFlow: function(flow) {
// technically the flow can have multiple steps, but no one does this
// for login and loginLogic doesn't support it so we can ignore it.
if (!this._stepRendererMap[flow.type]) {
console.log("Skipping flow", flow, "due to unsupported login type", flow.type);
return false;
}
return true;
},
_getCurrentFlowStep: function() { _getCurrentFlowStep: function() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
}, },
@ -256,17 +306,19 @@ module.exports = React.createClass({
!this.state.enteredHomeserverUrl.startsWith("http")) !this.state.enteredHomeserverUrl.startsWith("http"))
) { ) {
errorText = <span> errorText = <span>
{ _tJsx("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + {
_t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " +
"Either use HTTPS or <a>enable unsafe scripts</a>.", "Either use HTTPS or <a>enable unsafe scripts</a>.",
/<a>(.*?)<\/a>/, {},
(sub) => { return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">{ sub }</a>; }, { 'a': (sub) => { return <a href="https://www.google.com/search?&q=enable%20unsafe%20scripts">{ sub }</a>; } },
) } ) }
</span>; </span>;
} else { } else {
errorText = <span> errorText = <span>
{ _tJsx("Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.", {
/<a>(.*?)<\/a>/, _t("Can't connect to homeserver - please check your connectivity, ensure your <a>homeserver's SSL certificate</a> is trusted, and that a browser extension is not blocking requests.",
(sub) => { return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>; }, {},
{ 'a': (sub) => { return <a href={this.state.enteredHomeserverUrl}>{ sub }</a>; } },
) } ) }
</span>; </span>;
} }
@ -276,8 +328,20 @@ module.exports = React.createClass({
}, },
componentForStep: function(step) { componentForStep: function(step) {
switch (step) { if (!step) {
case 'm.login.password': return null;
}
const stepRenderer = this._stepRendererMap[step];
if (stepRenderer) {
return stepRenderer();
}
return null;
},
_renderPasswordStep: function() {
const PasswordLogin = sdk.getComponent('login.PasswordLogin'); const PasswordLogin = sdk.getComponent('login.PasswordLogin');
return ( return (
<PasswordLogin <PasswordLogin
@ -293,26 +357,18 @@ module.exports = React.createClass({
hsUrl={this.state.enteredHomeserverUrl} hsUrl={this.state.enteredHomeserverUrl}
/> />
); );
case 'm.login.cas': },
_renderCasStep: function() {
const CasLogin = sdk.getComponent('login.CasLogin'); const CasLogin = sdk.getComponent('login.CasLogin');
return ( return (
<CasLogin onSubmit={this.onCasLogin} /> <CasLogin onSubmit={this.onCasLogin} />
); );
default:
if (!step) {
return;
}
return (
<div>
{ _t('Sorry, this homeserver is using a login which is not recognised ') }({ step })
</div>
);
}
}, },
_onLanguageChange: function(newLang) { _onLanguageChange: function(newLang) {
if(languageHandler.getCurrentLanguage() !== newLang) { if (languageHandler.getCurrentLanguage() !== newLang) {
UserSettingsStore.setLocalSetting('language', newLang); SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLang);
PlatformPeg.get().reload(); PlatformPeg.get().reload();
} }
}, },
@ -329,6 +385,7 @@ module.exports = React.createClass({
render: function() { render: function() {
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const LoginPage = sdk.getComponent("login.LoginPage");
const LoginHeader = sdk.getComponent("login.LoginHeader"); const LoginHeader = sdk.getComponent("login.LoginHeader");
const LoginFooter = sdk.getComponent("login.LoginFooter"); const LoginFooter = sdk.getComponent("login.LoginFooter");
const ServerConfig = sdk.getComponent("login.ServerConfig"); const ServerConfig = sdk.getComponent("login.ServerConfig");
@ -343,43 +400,68 @@ module.exports = React.createClass({
} }
let returnToAppJsx; let returnToAppJsx;
/*
// with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
returnToAppJsx = returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#"> <a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
{ _t('Return to app') } { _t('Return to app') }
</a>; </a>;
} }
*/
return ( let serverConfig;
<div className="mx_Login"> let header;
<div className="mx_Login_box">
<LoginHeader /> if (!SdkConfig.get().disable_custom_urls) {
<div> serverConfig = <ServerConfig ref="serverConfig"
<h2>{ _t('Sign in') }
{ loader }
</h2>
{ this.componentForStep(this.state.currentFlow) }
<ServerConfig ref="serverConfig"
withToggleButton={true} withToggleButton={true}
customHsUrl={this.props.customHsUrl} customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl} customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl} defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl} defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000} /> delayTimeMs={1000} />;
}
// FIXME: remove status.im theme tweaks
const theme = SettingsStore.getValue("theme");
if (theme !== "status") {
header = <h2>{ _t('Sign in') }</h2>;
} else {
if (!this.state.errorText) {
header = <h2>{ _t('Sign in to get started') }</h2>;
}
}
let errorTextSection;
if (this.state.errorText) {
errorTextSection = (
<div className="mx_Login_error"> <div className="mx_Login_error">
{ this.state.errorText } { this.state.errorText }
</div> </div>
);
}
return (
<LoginPage>
<div className="mx_Login_box">
<LoginHeader />
<div>
{ header }
{ errorTextSection }
{ this.componentForStep(this.state.currentFlow) }
{ serverConfig }
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#"> <a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">
{ _t('Create an account') } { _t('Create an account') }
</a> </a>
{ loginAsGuestJsx } { loginAsGuestJsx }
{ returnToAppJsx } { returnToAppJsx }
{ this._renderLanguageSetting() } { !SdkConfig.get().disable_login_language_selector ? this._renderLanguageSetting() : '' }
<LoginFooter /> <LoginFooter />
</div> </div>
</div> </div>
</div> </LoginPage>
); );
}, },
}); });

View file

@ -59,9 +59,10 @@ module.exports = React.createClass({
render: function() { render: function() {
const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const LoginPage = sdk.getComponent('login.LoginPage');
const LoginHeader = sdk.getComponent('login.LoginHeader'); const LoginHeader = sdk.getComponent('login.LoginHeader');
return ( return (
<div className="mx_Login"> <LoginPage>
<div className="mx_Login_box"> <div className="mx_Login_box">
<LoginHeader /> <LoginHeader />
<div className="mx_Login_profile"> <div className="mx_Login_profile">
@ -74,7 +75,7 @@ module.exports = React.createClass({
{ this.state.errorString } { this.state.errorString }
</div> </div>
</div> </div>
</div> </LoginPage>
); );
}, },
}); });

View file

@ -26,6 +26,8 @@ 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 } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig';
import SettingsStore from "../../../settings/SettingsStore";
const MIN_PASSWORD_LENGTH = 6; const MIN_PASSWORD_LENGTH = 6;
@ -322,10 +324,13 @@ module.exports = React.createClass({
render: function() { render: function() {
const LoginHeader = sdk.getComponent('login.LoginHeader'); const LoginHeader = sdk.getComponent('login.LoginHeader');
const LoginFooter = sdk.getComponent('login.LoginFooter'); const LoginFooter = sdk.getComponent('login.LoginFooter');
const LoginPage = sdk.getComponent('login.LoginPage');
const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth');
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const ServerConfig = sdk.getComponent('views.login.ServerConfig'); const ServerConfig = sdk.getComponent('views.login.ServerConfig');
const theme = SettingsStore.getValue("theme");
let registerBody; let registerBody;
if (this.state.doingUIAuth) { if (this.state.doingUIAuth) {
registerBody = ( registerBody = (
@ -344,9 +349,19 @@ module.exports = React.createClass({
} else if (this.state.busy || this.state.teamServerBusy) { } else if (this.state.busy || this.state.teamServerBusy) {
registerBody = <Spinner />; registerBody = <Spinner />;
} else { } else {
let errorSection; let serverConfigSection;
if (this.state.errorText) { if (!SdkConfig.get().disable_custom_urls) {
errorSection = <div className="mx_Login_error">{ this.state.errorText }</div>; serverConfigSection = (
<ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000}
/>
);
} }
registerBody = ( registerBody = (
<div> <div>
@ -362,21 +377,14 @@ module.exports = React.createClass({
onRegisterClick={this.onFormSubmit} onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected} onTeamSelected={this.onTeamSelected}
/> />
{ errorSection } { serverConfigSection }
<ServerConfig ref="serverConfig"
withToggleButton={true}
customHsUrl={this.props.customHsUrl}
customIsUrl={this.props.customIsUrl}
defaultHsUrl={this.props.defaultHsUrl}
defaultIsUrl={this.props.defaultIsUrl}
onServerConfigChange={this.onServerConfigChange}
delayTimeMs={1000}
/>
</div> </div>
); );
} }
let returnToAppJsx; let returnToAppJsx;
/*
// with the advent of ILAG I don't think we need this any more
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
returnToAppJsx = ( returnToAppJsx = (
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#"> <a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
@ -384,8 +392,31 @@ module.exports = React.createClass({
</a> </a>
); );
} }
*/
let header;
let errorText;
// FIXME: remove hardcoded Status team tweaks at some point
if (theme === 'status' && this.state.errorText) {
header = <div className="mx_Login_error">{ this.state.errorText }</div>;
} else {
header = <h2>{ _t('Create an account') }</h2>;
if (this.state.errorText) {
errorText = <div className="mx_Login_error">{ this.state.errorText }</div>;
}
}
let signIn;
if (!this.state.doingUIAuth) {
signIn = (
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#">
{ theme === 'status' ? _t('Sign in') : _t('I already have an account') }
</a>
);
}
return ( return (
<div className="mx_Login"> <LoginPage>
<div className="mx_Login_box"> <div className="mx_Login_box">
<LoginHeader <LoginHeader
icon={this.state.teamSelected ? icon={this.state.teamSelected ?
@ -393,15 +424,14 @@ module.exports = React.createClass({
this.state.teamSelected.domain + "/icon.png" : this.state.teamSelected.domain + "/icon.png" :
null} null}
/> />
<h2>{ _t('Create an account') }</h2> { header }
{ registerBody } { registerBody }
<a className="mx_Login_create" onClick={this.props.onLoginClick} href="#"> { signIn }
{ _t('I already have an account') } { errorText }
</a>
{ returnToAppJsx } { returnToAppJsx }
<LoginFooter /> <LoginFooter />
</div> </div>
</div> </LoginPage>
); );
}, },
}); });

View file

@ -110,7 +110,7 @@ module.exports = React.createClass({
let idx = 0; let idx = 0;
const initial = name[0]; const initial = name[0];
if ((initial === '@' || initial === '#') && name[1]) { if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++; idx++;
} }

View file

@ -24,10 +24,12 @@ export default React.createClass({
propTypes: { propTypes: {
groupId: PropTypes.string, groupId: PropTypes.string,
groupName: PropTypes.string,
groupAvatarUrl: PropTypes.string, groupAvatarUrl: PropTypes.string,
width: PropTypes.number, width: PropTypes.number,
height: PropTypes.number, height: PropTypes.number,
resizeMethod: PropTypes.string, resizeMethod: PropTypes.string,
onClick: PropTypes.func,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -52,11 +54,11 @@ export default React.createClass({
// extract the props we use from props so we can pass any others through // extract the props we use from props so we can pass any others through
// should consider adding this as a global rule in js-sdk? // should consider adding this as a global rule in js-sdk?
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {groupId, groupAvatarUrl, ...otherProps} = this.props; const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props;
return ( return (
<BaseAvatar <BaseAvatar
name={this.props.groupId[1]} name={groupName || this.props.groupId[1]}
idName={this.props.groupId} idName={this.props.groupId}
url={this.getGroupAvatarUrl()} url={this.getGroupAvatarUrl()}
{...otherProps} {...otherProps}

View file

@ -0,0 +1,168 @@
/*
Copyright 2017 Travis Ralston
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.
*/
'use strict';
import React from "react";
import * as sdk from "../../../index";
import MatrixClientPeg from "../../../MatrixClientPeg";
import AccessibleButton from '../elements/AccessibleButton';
import Presence from "../../../Presence";
import dispatcher from "../../../dispatcher";
import * as ContextualMenu from "../../structures/ContextualMenu";
import SettingsStore from "../../../settings/SettingsStore";
// This is an avatar with presence information and controls on it.
module.exports = React.createClass({
displayName: 'MemberPresenceAvatar',
propTypes: {
member: React.PropTypes.object.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number,
resizeMethod: React.PropTypes.string,
},
getDefaultProps: function() {
return {
width: 40,
height: 40,
resizeMethod: 'crop',
};
},
getInitialState: function() {
let presenceState = null;
let presenceMessage = null;
// RoomMembers do not necessarily have a user.
if (this.props.member.user) {
presenceState = this.props.member.user.presence;
presenceMessage = this.props.member.user.presenceStatusMsg;
}
return {
status: presenceState,
message: presenceMessage,
};
},
componentWillMount: function() {
MatrixClientPeg.get().on("User.presence", this.onUserPresence);
this.dispatcherRef = dispatcher.register(this.onAction);
},
componentWillUnmount: function() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("User.presence", this.onUserPresence);
}
dispatcher.unregister(this.dispatcherRef);
},
onAction: function(payload) {
if (payload.action !== "self_presence_updated") return;
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) return;
this.setState({
status: payload.statusInfo.presence,
message: payload.statusInfo.status_msg,
});
},
onUserPresence: function(event, user) {
if (user.userId !== MatrixClientPeg.get().getUserId()) return;
this.setState({
status: user.presence,
message: user.presenceStatusMsg,
});
},
onStatusChange: function(newStatus) {
Presence.stopMaintainingStatus();
if (newStatus === "online") {
Presence.setState(newStatus);
} else Presence.setState(newStatus, null, true);
},
onClick: function(e) {
const PresenceContextMenu = sdk.getComponent('context_menus.PresenceContextMenu');
const elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = (elementRect.left + window.pageXOffset) - (elementRect.width / 2) + 3;
const chevronOffset = 12;
let y = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
y = y - (chevronOffset + 4); // where 4 is 1/4 the height of the chevron
ContextualMenu.createMenu(PresenceContextMenu, {
chevronOffset: chevronOffset,
chevronFace: 'bottom',
left: x,
top: y,
menuWidth: 125,
currentStatus: this.state.status,
onChange: this.onStatusChange,
});
e.stopPropagation();
// XXX NB the following assumes that user is non-null, which is not valid
// const presenceState = this.props.member.user.presence;
// const presenceLastActiveAgo = this.props.member.user.lastActiveAgo;
// const presenceLastTs = this.props.member.user.lastPresenceTs;
// const presenceCurrentlyActive = this.props.member.user.currentlyActive;
// const presenceMessage = this.props.member.user.presenceStatusMsg;
},
render: function() {
const MemberAvatar = sdk.getComponent("avatars.MemberAvatar");
let onClickFn = null;
if (this.props.member.userId === MatrixClientPeg.get().getUserId()) {
onClickFn = this.onClick;
}
const avatarNode = (
<MemberAvatar member={this.props.member} width={this.props.width} height={this.props.height}
resizeMethod={this.props.resizeMethod} />
);
let statusNode = (
<span className={"mx_MemberPresenceAvatar_status mx_MemberPresenceAvatar_status_" + this.state.status} />
);
// LABS: Disable presence management functions for now
// Also disable the presence information if there's no status information
if (!SettingsStore.isFeatureEnabled("feature_presence_management") || !this.state.status) {
statusNode = null;
onClickFn = null;
}
let avatar = (
<div className="mx_MemberPresenceAvatar">
{ avatarNode }
{ statusNode }
</div>
);
if (onClickFn) {
avatar = (
<AccessibleButton onClick={onClickFn} className="mx_MemberPresenceAvatar" element="div">
{ avatarNode }
{ statusNode }
</AccessibleButton>
);
}
return avatar;
},
});

View file

@ -34,6 +34,8 @@ module.exports = React.createClass({
propTypes: { propTypes: {
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
description: PropTypes.node, description: PropTypes.node,
// Extra node inserted after picker input, dropdown and errors
extraNode: PropTypes.node,
value: PropTypes.string, value: PropTypes.string,
placeholder: PropTypes.string, placeholder: PropTypes.string,
roomId: PropTypes.string, roomId: PropTypes.string,
@ -242,7 +244,7 @@ module.exports = React.createClass({
_doNaiveGroupRoomSearch: function(query) { _doNaiveGroupRoomSearch: function(query) {
const lowerCaseQuery = query.toLowerCase(); const lowerCaseQuery = query.toLowerCase();
const groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), this.props.groupId); const groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
const results = []; const results = [];
groupStore.getGroupRooms().forEach((r) => { groupStore.getGroupRooms().forEach((r) => {
const nameMatch = (r.name || '').toLowerCase().includes(lowerCaseQuery); const nameMatch = (r.name || '').toLowerCase().includes(lowerCaseQuery);
@ -268,27 +270,53 @@ module.exports = React.createClass({
const rooms = MatrixClientPeg.get().getRooms(); const rooms = MatrixClientPeg.get().getRooms();
const results = []; const results = [];
rooms.forEach((room) => { rooms.forEach((room) => {
let rank = Infinity;
const nameEvent = room.currentState.getStateEvents('m.room.name', ''); const nameEvent = room.currentState.getStateEvents('m.room.name', '');
const topicEvent = room.currentState.getStateEvents('m.room.topic', '');
const name = nameEvent ? nameEvent.getContent().name : ''; const name = nameEvent ? nameEvent.getContent().name : '';
const canonicalAlias = room.getCanonicalAlias(); const canonicalAlias = room.getCanonicalAlias();
const topic = topicEvent ? topicEvent.getContent().topic : ''; const aliasEvents = room.currentState.getStateEvents('m.room.aliases');
const aliases = aliasEvents.map((ev) => ev.getContent().aliases).reduce((a, b) => {
return a.concat(b);
}, []);
const nameMatch = (name || '').toLowerCase().includes(lowerCaseQuery); const nameMatch = (name || '').toLowerCase().includes(lowerCaseQuery);
const aliasMatch = (canonicalAlias || '').toLowerCase().includes(lowerCaseQuery); let aliasMatch = false;
const topicMatch = (topic || '').toLowerCase().includes(lowerCaseQuery); let shortestMatchingAliasLength = Infinity;
if (!(nameMatch || topicMatch || aliasMatch)) { aliases.forEach((alias) => {
if ((alias || '').toLowerCase().includes(lowerCaseQuery)) {
aliasMatch = true;
if (shortestMatchingAliasLength > alias.length) {
shortestMatchingAliasLength = alias.length;
}
}
});
if (!(nameMatch || aliasMatch)) {
return; return;
} }
if (aliasMatch) {
// A shorter matching alias will give a better rank
rank = shortestMatchingAliasLength;
}
const avatarEvent = room.currentState.getStateEvents('m.room.avatar', ''); const avatarEvent = room.currentState.getStateEvents('m.room.avatar', '');
const avatarUrl = avatarEvent ? avatarEvent.getContent().url : undefined; const avatarUrl = avatarEvent ? avatarEvent.getContent().url : undefined;
results.push({ results.push({
rank,
room_id: room.roomId, room_id: room.roomId,
avatar_url: avatarUrl, avatar_url: avatarUrl,
name: name || canonicalAlias, name: name || canonicalAlias || aliases[0] || _t('Unnamed Room'),
}); });
}); });
this._processResults(results, query);
// Sort by rank ascending (a high rank being less relevant)
const sortedResults = results.sort((a, b) => {
return a.rank - b.rank;
});
this._processResults(sortedResults, query);
this.setState({ this.setState({
busy: false, busy: false,
}); });
@ -489,7 +517,12 @@ module.exports = React.createClass({
const AddressTile = sdk.getComponent("elements.AddressTile"); const AddressTile = sdk.getComponent("elements.AddressTile");
for (let i = 0; i < this.state.userList.length; i++) { for (let i = 0; i < this.state.userList.length; i++) {
query.push( query.push(
<AddressTile key={i} address={this.state.userList[i]} canDismiss={true} onDismissed={this.onDismissed(i)} />, <AddressTile
key={i}
address={this.state.userList[i]}
canDismiss={true}
onDismissed={this.onDismissed(i)}
showAddress={this.props.pickerType === 'user'} />,
); );
} }
} }
@ -539,6 +572,7 @@ module.exports = React.createClass({
addressSelector = ( addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref;}} <AddressSelector ref={(ref) => {this.addressSelector = ref;}}
addressList={this.state.queryList} addressList={this.state.queryList}
showAddress={this.props.pickerType === 'user'}
onSelected={this.onSelected} onSelected={this.onSelected}
truncateAt={TRUNCATE_QUERY_LIST} truncateAt={TRUNCATE_QUERY_LIST}
/> />
@ -561,6 +595,7 @@ module.exports = React.createClass({
<div className="mx_ChatInviteDialog_inputContainer">{ query }</div> <div className="mx_ChatInviteDialog_inputContainer">{ query }</div>
{ error } { error }
{ addressSelector } { addressSelector }
{ this.props.extraNode }
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.onButtonClick}> <button className="mx_Dialog_primary" onClick={this.onButtonClick}>

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import * as KeyCode from '../../../KeyCode'; import { KeyCode } from '../../../Keyboard';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index'; import sdk from '../../../index';

View file

@ -16,7 +16,6 @@ limitations under the License.
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import classnames from 'classnames';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
/* /*
@ -24,51 +23,17 @@ import { _t } from '../../../languageHandler';
*/ */
export default React.createClass({ export default React.createClass({
displayName: 'ConfirmRedactDialog', displayName: 'ConfirmRedactDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
},
defaultProps: {
danger: false,
},
onOk: function() {
this.props.onFinished(true);
},
onCancel: function() {
this.props.onFinished(false);
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
const title = _t("Confirm Removal");
const confirmButtonClass = classnames({
'mx_Dialog_primary': true,
'danger': false,
});
return ( return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished} <QuestionDialog onFinished={this.props.onFinished}
onEnterPressed={this.onOk} title={_t("Confirm Removal")}
title={title} description={
> _t("Are you sure you wish to remove (delete) this event? " +
<div className="mx_Dialog_content"> "Note that if you delete a room name or topic change, it could undo the change.")}
{ _t("Are you sure you wish to remove (delete) this event? " + button={_t("Remove")}>
"Note that if you delete a room name or topic change, it could undo the change.") } </QuestionDialog>
</div>
<div className="mx_Dialog_buttons">
<button className={confirmButtonClass} onClick={this.onOk}>
{ _t("Remove") }
</button>
<button onClick={this.onCancel}>
{ _t("Cancel") }
</button>
</div>
</BaseDialog>
); );
}, },
}); });

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index'; import sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import classnames from 'classnames'; import classnames from 'classnames';
@ -35,7 +36,10 @@ export default React.createClass({
member: React.PropTypes.object, member: React.PropTypes.object,
// group member object. Supply either this or 'member' // group member object. Supply either this or 'member'
groupMember: GroupMemberType, groupMember: GroupMemberType,
// needed if a group member is specified
matrixClient: React.PropTypes.instanceOf(MatrixClient),
action: React.PropTypes.string.isRequired, // eg. 'Ban' action: React.PropTypes.string.isRequired, // eg. 'Ban'
title: React.PropTypes.string.isRequired, // eg. 'Ban this user?'
// Whether to display a text field for a reason // Whether to display a text field for a reason
// If true, the second argument to onFinished will // If true, the second argument to onFinished will
@ -75,7 +79,6 @@ export default React.createClass({
const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
const title = _t("%(actionVerb)s this person?", { actionVerb: this.props.action});
const confirmButtonClass = classnames({ const confirmButtonClass = classnames({
'mx_Dialog_primary': true, 'mx_Dialog_primary': true,
'danger': this.props.danger, 'danger': this.props.danger,
@ -104,16 +107,17 @@ export default React.createClass({
name = this.props.member.name; name = this.props.member.name;
userId = this.props.member.userId; userId = this.props.member.userId;
} else { } else {
// we don't get this info from the API yet const httpAvatarUrl = this.props.groupMember.avatarUrl ?
avatar = <BaseAvatar name={this.props.groupMember.userId} width={48} height={48} />; this.props.matrixClient.mxcUrlToHttp(this.props.groupMember.avatarUrl, 48, 48) : null;
name = this.props.groupMember.userId; name = this.props.groupMember.displayname || this.props.groupMember.userId;
userId = this.props.groupMember.userId; userId = this.props.groupMember.userId;
avatar = <BaseAvatar name={name} url={httpAvatarUrl} width={48} height={48} />;
} }
return ( return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk} onEnterPressed={this.onOk}
title={title} title={this.props.title}
> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_avatar"> <div className="mx_ConfirmUserActionDialog_avatar">

View file

@ -21,10 +21,6 @@ import dis from '../../../dispatcher';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
// We match fairly liberally and leave it up to the server to reject if
// there are invalid characters etc.
const GROUP_REGEX = /^\+(.*?):(.*)$/;
export default React.createClass({ export default React.createClass({
displayName: 'CreateGroupDialog', displayName: 'CreateGroupDialog',
propTypes: { propTypes: {
@ -58,22 +54,9 @@ export default React.createClass({
}, },
_checkGroupId: function(e) { _checkGroupId: function(e) {
const parsedGroupId = this._parseGroupId(this.state.groupId);
let error = null; let error = null;
if (parsedGroupId === null) { if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) {
error = _t( error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'");
"Group IDs must be of the form +localpart:%(domain)s",
{domain: MatrixClientPeg.get().getDomain()},
);
} else {
const domain = parsedGroupId[1];
if (domain !== MatrixClientPeg.get().getDomain()) {
error = _t(
"It is currently only possible to create groups on your own home server: "+
"use a group ID ending with %(domain)s",
{domain: MatrixClientPeg.get().getDomain()},
);
}
} }
this.setState({ this.setState({
groupIdError: error, groupIdError: error,
@ -86,19 +69,19 @@ export default React.createClass({
if (this._checkGroupId()) return; if (this._checkGroupId()) return;
const parsedGroupId = this._parseGroupId(this.state.groupId);
const profile = {}; const profile = {};
if (this.state.groupName !== '') { if (this.state.groupName !== '') {
profile.name = this.state.groupName; profile.name = this.state.groupName;
} }
this.setState({creating: true}); this.setState({creating: true});
MatrixClientPeg.get().createGroup({ MatrixClientPeg.get().createGroup({
localpart: parsedGroupId[0], localpart: this.state.groupId,
profile: profile, profile: profile,
}).then((result) => { }).then((result) => {
dis.dispatch({ dis.dispatch({
action: 'view_group', action: 'view_group',
group_id: result.group_id, group_id: result.group_id,
group_is_new: true,
}); });
this.props.onFinished(true); this.props.onFinished(true);
}).catch((e) => { }).catch((e) => {
@ -112,22 +95,6 @@ export default React.createClass({
this.props.onFinished(false); this.props.onFinished(false);
}, },
/**
* Parse a string that may be a group ID
* If the string is a valid group ID, return a list of [localpart, domain],
* otherwise return null.
*
* @param {string} groupId The ID of the group
* @return {string[]} array of localpart, domain
*/
_parseGroupId: function(groupId) {
const matches = GROUP_REGEX.exec(this.state.groupId);
if (!matches || matches.length < 3) {
return null;
}
return [matches[1], matches[2]];
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Spinner = sdk.getComponent('elements.Spinner'); const Spinner = sdk.getComponent('elements.Spinner');
@ -142,7 +109,7 @@ export default React.createClass({
// rather than displaying what the server gives us, but synapse doesn't give // rather than displaying what the server gives us, but synapse doesn't give
// any yet. // any yet.
createErrorNode = <div className="error"> createErrorNode = <div className="error">
<div>{ _t('Room creation failed') }</div> <div>{ _t('Something went wrong whilst creating your community') }</div>
<div>{ this.state.createError.message }</div> <div>{ this.state.createError.message }</div>
</div>; </div>;
} }
@ -150,13 +117,13 @@ export default React.createClass({
return ( return (
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
onEnterPressed={this._onFormSubmit} onEnterPressed={this._onFormSubmit}
title={_t('Create Group')} title={_t('Create Community')}
> >
<form onSubmit={this._onFormSubmit}> <form onSubmit={this._onFormSubmit}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_CreateGroupDialog_inputRow"> <div className="mx_CreateGroupDialog_inputRow">
<div className="mx_CreateGroupDialog_label"> <div className="mx_CreateGroupDialog_label">
<label htmlFor="groupname">{ _t('Group Name') }</label> <label htmlFor="groupname">{ _t('Community Name') }</label>
</div> </div>
<div> <div>
<input id="groupname" className="mx_CreateGroupDialog_input" <input id="groupname" className="mx_CreateGroupDialog_input"
@ -169,16 +136,21 @@ export default React.createClass({
</div> </div>
<div className="mx_CreateGroupDialog_inputRow"> <div className="mx_CreateGroupDialog_inputRow">
<div className="mx_CreateGroupDialog_label"> <div className="mx_CreateGroupDialog_label">
<label htmlFor="groupid">{ _t('Group ID') }</label> <label htmlFor="groupid">{ _t('Community ID') }</label>
</div> </div>
<div> <div className="mx_CreateGroupDialog_input_group">
<input id="groupid" className="mx_CreateGroupDialog_input" <span className="mx_CreateGroupDialog_prefix">+</span>
size="64" <input id="groupid"
placeholder={_t('+example:%(domain)s', {domain: MatrixClientPeg.get().getDomain()})} className="mx_CreateGroupDialog_input mx_CreateGroupDialog_input_hasPrefixAndSuffix"
size="32"
placeholder={_t('example')}
onChange={this._onGroupIdChange} onChange={this._onGroupIdChange}
onBlur={this._onGroupIdBlur} onBlur={this._onGroupIdBlur}
value={this.state.groupId} value={this.state.groupId}
/> />
<span className="mx_CreateGroupDialog_suffix">
:{ MatrixClientPeg.get().getDomain() }
</span>
</div> </div>
</div> </div>
<div className="error"> <div className="error">

View file

@ -54,7 +54,7 @@ export default React.createClass({
const deviceInfo = r[userId][deviceId]; const deviceInfo = r[userId][deviceId];
if(!deviceInfo) { if (!deviceInfo) {
console.warn(`No details found for device ${userId}:${deviceId}`); console.warn(`No details found for device ${userId}:${deviceId}`);
this.props.onFinished(false); this.props.onFinished(false);

View file

@ -18,7 +18,7 @@ import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t, _tJsx } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
export default React.createClass({ export default React.createClass({
@ -45,9 +45,10 @@ export default React.createClass({
if (SdkConfig.get().bug_report_endpoint_url) { if (SdkConfig.get().bug_report_endpoint_url) {
bugreport = ( bugreport = (
<p> <p>
{ _tJsx( { _t(
"Otherwise, <a>click here</a> to send a bug report.", "Otherwise, <a>click here</a> to send a bug report.",
/<a>(.*?)<\/a>/, (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a>, {},
{ 'a': (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a> },
) } ) }
</p> </p>
); );

View file

@ -20,8 +20,8 @@ import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import classnames from 'classnames'; import classnames from 'classnames';
import KeyCode from '../../../KeyCode'; import { KeyCode } from '../../../Keyboard';
import { _t, _tJsx } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
// The amount of time to wait for further changes to the input username before // The amount of time to wait for further changes to the input username before
// sending a request to the server // sending a request to the server
@ -267,24 +267,21 @@ export default React.createClass({
</div> </div>
{ usernameIndicator } { usernameIndicator }
<p> <p>
{ _tJsx( { _t(
'This will be your account name on the <span></span> ' + 'This will be your account name on the <span></span> ' +
'homeserver, or you can pick a <a>different server</a>.', 'homeserver, or you can pick a <a>different server</a>.',
[ {},
/<span><\/span>/, {
/<a>(.*?)<\/a>/, 'span': <span>{ this.props.homeserverUrl }</span>,
], 'a': (sub) => <a href="#" onClick={this.props.onDifferentServerClicked}>{ sub }</a>,
[ },
(sub) => <span>{ this.props.homeserverUrl }</span>,
(sub) => <a href="#" onClick={this.props.onDifferentServerClicked}>{ sub }</a>,
],
) } ) }
</p> </p>
<p> <p>
{ _tJsx( { _t(
'If you already have a Matrix account you can <a>log in</a> instead.', 'If you already have a Matrix account you can <a>log in</a> instead.',
/<a>(.*?)<\/a>/, {},
[(sub) => <a href="#" onClick={this.props.onLoginClick}>{ sub }</a>], { 'a': (sub) => <a href="#" onClick={this.props.onLoginClick}>{ sub }</a> },
) } ) }
</p> </p>
{ auth } { auth }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Copyright 2017 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,11 +16,21 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import GeminiScrollbar from 'react-gemini-scrollbar'; import GeminiScrollbar from 'react-gemini-scrollbar';
import Resend from '../../../Resend'; import Resend from '../../../Resend';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
function markAllDevicesKnown(devices) {
Object.keys(devices).forEach((userId) => {
Object.keys(devices[userId]).map((deviceId) => {
MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
});
});
}
function DeviceListEntry(props) { function DeviceListEntry(props) {
const {userId, device} = props; const {userId, device} = props;
@ -37,10 +48,10 @@ function DeviceListEntry(props) {
} }
DeviceListEntry.propTypes = { DeviceListEntry.propTypes = {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
// deviceinfo // deviceinfo
device: React.PropTypes.object.isRequired, device: PropTypes.object.isRequired,
}; };
@ -60,10 +71,10 @@ function UserUnknownDeviceList(props) {
} }
UserUnknownDeviceList.propTypes = { UserUnknownDeviceList.propTypes = {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
// map from deviceid -> deviceinfo // map from deviceid -> deviceinfo
userDevices: React.PropTypes.object.isRequired, userDevices: PropTypes.object.isRequired,
}; };
@ -82,7 +93,7 @@ function UnknownDeviceList(props) {
UnknownDeviceList.propTypes = { UnknownDeviceList.propTypes = {
// map from userid -> deviceid -> deviceinfo // map from userid -> deviceid -> deviceinfo
devices: React.PropTypes.object.isRequired, devices: PropTypes.object.isRequired,
}; };
@ -90,34 +101,65 @@ export default React.createClass({
displayName: 'UnknownDeviceDialog', displayName: 'UnknownDeviceDialog',
propTypes: { propTypes: {
room: React.PropTypes.object.isRequired, room: PropTypes.object.isRequired,
// map from userid -> deviceid -> deviceinfo // map from userid -> deviceid -> deviceinfo or null if devices are not yet loaded
devices: React.PropTypes.object.isRequired, devices: PropTypes.object,
onFinished: React.PropTypes.func.isRequired,
onFinished: PropTypes.func.isRequired,
// Label for the button that marks all devices known and tries the send again
sendAnywayLabel: PropTypes.string.isRequired,
// Label for the button that to send the event if you've verified all devices
sendLabel: PropTypes.string.isRequired,
// function to retry the request once all devices are verified / known
onSend: PropTypes.func.isRequired,
}, },
componentDidMount: function() { componentWillMount: function() {
// Given we've now shown the user the unknown device, it is no longer MatrixClientPeg.get().on("deviceVerificationChanged", this._onDeviceVerificationChanged);
// unknown to them. Therefore mark it as 'known'. },
Object.keys(this.props.devices).forEach((userId) => {
Object.keys(this.props.devices[userId]).map((deviceId) => {
MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true);
});
});
// XXX: temporary logging to try to diagnose componentWillUnmount: function() {
// https://github.com/vector-im/riot-web/issues/3148 if (MatrixClientPeg.get()) {
console.log('Opening UnknownDeviceDialog'); MatrixClientPeg.get().removeListener("deviceVerificationChanged", this._onDeviceVerificationChanged);
}
},
_onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) {
if (this.props.devices[userId] && this.props.devices[userId][deviceId]) {
// XXX: Mutating props :/
this.props.devices[userId][deviceId] = deviceInfo;
this.forceUpdate();
}
},
_onDismissClicked: function() {
this.props.onFinished();
},
_onSendAnywayClicked: function() {
markAllDevicesKnown(this.props.devices);
this.props.onFinished();
this.props.onSend();
},
_onSendClicked: function() {
this.props.onFinished();
this.props.onSend();
}, },
render: function() { render: function() {
const client = MatrixClientPeg.get(); if (this.props.devices === null) {
const blacklistUnverified = client.getGlobalBlacklistUnverifiedDevices() || const Spinner = sdk.getComponent("elements.Spinner");
this.props.room.getBlacklistUnverifiedDevices(); return <Spinner />;
}
let warning; let warning;
if (blacklistUnverified) { if (SettingsStore.getValue("blacklistUnverifiedDevices", this.props.room.roomId)) {
warning = ( warning = (
<h4> <h4>
{ _t("You are currently blacklisting unverified devices; to send " + { _t("You are currently blacklisting unverified devices; to send " +
@ -136,15 +178,30 @@ export default React.createClass({
); );
} }
let haveUnknownDevices = false;
Object.keys(this.props.devices).forEach((userId) => {
Object.keys(this.props.devices[userId]).map((deviceId) => {
const device = this.props.devices[userId][deviceId];
if (device.isUnverified() && !device.isKnown()) {
haveUnknownDevices = true;
}
});
});
let sendButton;
if (haveUnknownDevices) {
sendButton = <button onClick={this._onSendAnywayClicked}>
{ this.props.sendAnywayLabel }
</button>;
} else {
sendButton = <button onClick={this._onSendClicked}>
{ this.props.sendLabel }
</button>;
}
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<BaseDialog className='mx_UnknownDeviceDialog' <BaseDialog className='mx_UnknownDeviceDialog'
onFinished={() => { onFinished={this.props.onFinished}
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log("UnknownDeviceDialog closed by escape");
this.props.onFinished();
}}
title={_t('Room contains unknown devices')} title={_t('Room contains unknown devices')}
> >
<GeminiScrollbar autoshow={false} className="mx_Dialog_content"> <GeminiScrollbar autoshow={false} className="mx_Dialog_content">
@ -157,21 +214,11 @@ export default React.createClass({
<UnknownDeviceList devices={this.props.devices} /> <UnknownDeviceList devices={this.props.devices} />
</GeminiScrollbar> </GeminiScrollbar>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
{sendButton}
<button className="mx_Dialog_primary" autoFocus={true} <button className="mx_Dialog_primary" autoFocus={true}
onClick={() => { onClick={this._onDismissClicked}
this.props.onFinished(); >
Resend.resendUnsentEvents(this.props.room); {_t("Dismiss")}
}}>
{ _t("Send anyway") }
</button>
<button className="mx_Dialog_primary" autoFocus={true}
onClick={() => {
// XXX: temporary logging to try to diagnose
// https://github.com/vector-im/riot-web/issues/3148
console.log("UnknownDeviceDialog closed by OK");
this.props.onFinished();
}}>
OK
</button> </button>
</div> </div>
</BaseDialog> </BaseDialog>

View file

@ -77,6 +77,7 @@ export default React.createClass({
onClick={this._onClick} onClick={this._onClick}
onMouseEnter={this._onMouseEnter} onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave} onMouseLeave={this._onMouseLeave}
aria-label={this.props.label}
> >
<TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} /> <TintableSvg src={this.props.iconPath} width={this.props.size} height={this.props.size} />
{ tooltip } { tooltip }

View file

@ -30,6 +30,8 @@ export default React.createClass({
// List of the addresses to display // List of the addresses to display
addressList: React.PropTypes.arrayOf(UserAddressType).isRequired, addressList: React.PropTypes.arrayOf(UserAddressType).isRequired,
// Whether to show the address on the address tiles
showAddress: React.PropTypes.bool,
truncateAt: React.PropTypes.number.isRequired, truncateAt: React.PropTypes.number.isRequired,
selected: React.PropTypes.number, selected: React.PropTypes.number,
@ -142,7 +144,13 @@ export default React.createClass({
key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address} key={this.props.addressList[i].addressType + "/" + this.props.addressList[i].address}
ref={(ref) => { this.addressListElement = ref; }} ref={(ref) => { this.addressListElement = ref; }}
> >
<AddressTile address={this.props.addressList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" /> <AddressTile
address={this.props.addressList[i]}
showAddress={this.props.showAddress}
justified={true}
networkName="vector"
networkUrl="img/search-icon-vector.svg"
/>
</div>, </div>,
); );
} }

View file

@ -87,7 +87,10 @@ export default React.createClass({
info = ( info = (
<div className="mx_AddressTile_mx"> <div className="mx_AddressTile_mx">
<div className={nameClasses}>{ name }</div> <div className={nameClasses}>{ name }</div>
<div className={idClasses}>{ address.address }</div> { this.props.showAddress ?
<div className={idClasses}>{ address.address }</div> :
<div />
}
</div> </div>
); );
} else if (isMatrixAddress) { } else if (isMatrixAddress) {

View file

@ -19,9 +19,9 @@ export default class AppPermission extends React.Component {
const searchParams = new URLSearchParams(wurl.search); const searchParams = new URLSearchParams(wurl.search);
if(this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) { if (this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) {
curl = url.parse(searchParams.get('url')); curl = url.parse(searchParams.get('url'));
if(curl) { if (curl) {
curl.search = curl.query = ""; curl.search = curl.query = "";
curlString = curl.format(); curlString = curl.format();
} }
@ -34,7 +34,7 @@ export default class AppPermission extends React.Component {
} }
isScalarWurl(wurl) { isScalarWurl(wurl) {
if(wurl && wurl.hostname && ( if (wurl && wurl.hostname && (
wurl.hostname === 'scalar.vector.im' || wurl.hostname === 'scalar.vector.im' ||
wurl.hostname === 'scalar-staging.riot.im' || wurl.hostname === 'scalar-staging.riot.im' ||
wurl.hostname === 'scalar-develop.riot.im' || wurl.hostname === 'scalar-develop.riot.im' ||

View file

@ -17,10 +17,13 @@ limitations under the License.
'use strict'; 'use strict';
import url from 'url'; import url from 'url';
import qs from 'querystring';
import React from 'react'; import React from 'react';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg'; import PlatformPeg from '../../../PlatformPeg';
import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarAuthClient from '../../../ScalarAuthClient';
import WidgetMessaging from '../../../WidgetMessaging';
import TintableSvgButton from './TintableSvgButton';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
@ -49,44 +52,96 @@ export default React.createClass({
userId: React.PropTypes.string.isRequired, userId: React.PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget // UserId of the entity that added / modified the widget
creatorUserId: React.PropTypes.string, creatorUserId: React.PropTypes.string,
waitForIframeLoad: React.PropTypes.bool,
}, },
getDefaultProps: function() { getDefaultProps() {
return { return {
url: "", url: "",
waitForIframeLoad: true,
}; };
}, },
getInitialState: function() { /**
const widgetPermissionId = [this.props.room.roomId, encodeURIComponent(this.props.url)].join('_'); * Set initial component state when the App wUrl (widget URL) is being updated.
* Component props *must* be passed (rather than relying on this.props).
* @param {Object} newProps The new properties of the component
* @return {Object} Updated component state to be set with setState
*/
_getNewState(newProps) {
const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_');
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);
return { return {
loading: false, initialising: true, // True while we are mangling the widget URL
widgetUrl: this.props.url, loading: this.props.waitForIframeLoad, // True while the iframe content is loading
widgetUrl: this._addWurlParams(newProps.url),
widgetPermissionId: widgetPermissionId, widgetPermissionId: widgetPermissionId,
// Assume that widget has permission to load if we are the user who added it to the room, or if explicitly granted by the user // Assume that widget has permission to load if we are the user who
hasPermissionToLoad: hasPermissionToLoad === 'true' || this.props.userId === this.props.creatorUserId, // added it to the room, or if explicitly granted by the user
hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId,
error: null, error: null,
deleting: false, deleting: false,
widgetPageTitle: newProps.widgetPageTitle,
}; };
}, },
// Returns true if props.url is a scalar URL, typically https://scalar.vector.im/api /**
isScalarUrl: function() { * Add widget instance specific parameters to pass in wUrl
* Properties passed to widget instance:
* - widgetId
* - origin / parent URL
* @param {string} urlString Url string to modify
* @return {string}
* Url string with parameters appended.
* If url can not be parsed, it is returned unmodified.
*/
_addWurlParams(urlString) {
const u = url.parse(urlString);
if (!u) {
console.error("_addWurlParams", "Invalid URL", urlString);
return url;
}
const params = qs.parse(u.query);
// Append widget ID to query parameters
params.widgetId = this.props.id;
// Append current / parent URL
params.parentUrl = window.location.href;
u.search = undefined;
u.query = params;
return u.format();
},
getInitialState() {
return this._getNewState(this.props);
},
/**
* Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api
* @param {[type]} url URL to check
* @return {Boolean} True if specified URL is a scalar URL
*/
isScalarUrl(url) {
if (!url) {
console.error('Scalar URL check failed. No URL specified');
return false;
}
let scalarUrls = SdkConfig.get().integrations_widgets_urls; let scalarUrls = SdkConfig.get().integrations_widgets_urls;
if (!scalarUrls || scalarUrls.length == 0) { if (!scalarUrls || scalarUrls.length == 0) {
scalarUrls = [SdkConfig.get().integrations_rest_url]; scalarUrls = [SdkConfig.get().integrations_rest_url];
} }
for (let i = 0; i < scalarUrls.length; i++) { for (let i = 0; i < scalarUrls.length; i++) {
if (this.props.url.startsWith(scalarUrls[i])) { if (url.startsWith(scalarUrls[i])) {
return true; return true;
} }
} }
return false; return false;
}, },
isMixedContent: function() { isMixedContent() {
const parentContentProtocol = window.location.protocol; const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.url); const u = url.parse(this.props.url);
const childContentProtocol = u.protocol; const childContentProtocol = u.protocol;
@ -98,43 +153,86 @@ export default React.createClass({
return false; return false;
}, },
componentWillMount: function() { componentWillMount() {
if (!this.isScalarUrl()) { WidgetMessaging.startListening();
WidgetMessaging.addEndpoint(this.props.id, this.props.url);
window.addEventListener('message', this._onMessage, false);
this.setScalarToken();
},
/**
* Adds a scalar token to the widget URL, if required
* Component initialisation is only complete when this function has resolved
*/
setScalarToken() {
this.setState({initialising: true});
if (!this.isScalarUrl(this.props.url)) {
console.warn('Non-scalar widget, not setting scalar token!', url);
this.setState({
error: null,
widgetUrl: this._addWurlParams(this.props.url),
initialising: false,
});
return; return;
} }
// Fetch the token before loading the iframe as we need to mangle the URL
this.setState({ // Fetch the token before loading the iframe as we need it to mangle the URL
loading: true, if (!this._scalarClient) {
});
this._scalarClient = new ScalarAuthClient(); this._scalarClient = new ScalarAuthClient();
}
this._scalarClient.getScalarToken().done((token) => { this._scalarClient.getScalarToken().done((token) => {
// Append scalar_token as a query param // Append scalar_token as a query param if not already present
this._scalarClient.scalarToken = token; this._scalarClient.scalarToken = token;
const u = url.parse(this.props.url); const u = url.parse(this._addWurlParams(this.props.url));
if (!u.search) { const params = qs.parse(u.query);
u.search = "?scalar_token=" + encodeURIComponent(token); if (!params.scalar_token) {
} else { params.scalar_token = encodeURIComponent(token);
u.search += "&scalar_token=" + encodeURIComponent(token); // u.search must be set to undefined, so that u.format() uses query paramerters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options
u.search = undefined;
u.query = params;
} }
this.setState({ this.setState({
error: null, error: null,
widgetUrl: u.format(), widgetUrl: u.format(),
loading: false, initialising: false,
}); });
// Fetch page title from remote content if not already set
if (!this.state.widgetPageTitle && params.url) {
this._fetchWidgetTitle(params.url);
}
}, (err) => { }, (err) => {
console.error("Failed to get scalar_token", err);
this.setState({ this.setState({
error: err.message, error: err.message,
loading: false, initialising: false,
}); });
}); });
window.addEventListener('message', this._onMessage, false);
}, },
componentWillUnmount() { componentWillUnmount() {
WidgetMessaging.stopListening();
WidgetMessaging.removeEndpoint(this.props.id, this.props.url);
window.removeEventListener('message', this._onMessage); window.removeEventListener('message', this._onMessage);
}, },
componentWillReceiveProps(nextProps) {
if (nextProps.url !== this.props.url) {
this._getNewState(nextProps);
this.setScalarToken();
} else if (nextProps.show && !this.props.show && this.props.waitForIframeLoad) {
this.setState({
loading: true,
});
} else if (nextProps.widgetPageTitle !== this.props.widgetPageTitle) {
this.setState({
widgetPageTitle: nextProps.widgetPageTitle,
});
}
},
_onMessage(event) { _onMessage(event) {
if (this.props.type !== 'jitsi') { if (this.props.type !== 'jitsi') {
return; return;
@ -154,11 +252,11 @@ export default React.createClass({
} }
}, },
_canUserModify: function() { _canUserModify() {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
}, },
_onEditClick: function(e) { _onEditClick(e) {
console.log("Edit widget ID ", this.props.id); console.log("Edit widget ID ", this.props.id);
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = this._scalarClient.getScalarInterfaceUrlForRoom( const src = this._scalarClient.getScalarInterfaceUrlForRoom(
@ -168,29 +266,62 @@ export default React.createClass({
}, "mx_IntegrationsManager"); }, "mx_IntegrationsManager");
}, },
/* If user has permission to modify widgets, delete the widget, otherwise revoke access for the widget to load in the user's browser /* If user has permission to modify widgets, delete the widget,
* otherwise revoke access for the widget to load in the user's browser
*/ */
_onDeleteClick: function() { _onDeleteClick() {
if (this._canUserModify()) { if (this._canUserModify()) {
console.log("Delete widget %s", this.props.id); // Show delete confirmation dialog
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, {
title: _t("Delete Widget"),
description: _t(
"Deleting a widget removes it for all users in this room." +
" Are you sure you want to delete this widget?"),
button: _t("Delete widget"),
onFinished: (confirmed) => {
if (!confirmed) {
return;
}
this.setState({deleting: true}); this.setState({deleting: true});
MatrixClientPeg.get().sendStateEvent( MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId, this.props.room.roomId,
'im.vector.modular.widgets', 'im.vector.modular.widgets',
{}, // empty content {}, // empty content
this.props.id, this.props.id,
).then(() => { ).catch((e) => {
console.log('Deleted widget');
}, (e) => {
console.error('Failed to delete widget', e); console.error('Failed to delete widget', e);
this.setState({deleting: false}); this.setState({deleting: false});
}); });
},
});
} else { } else {
console.log("Revoke widget permissions - %s", this.props.id); console.log("Revoke widget permissions - %s", this.props.id);
this._revokeWidgetPermission(); this._revokeWidgetPermission();
} }
}, },
/**
* Called when widget iframe has finished loading
*/
_onLoaded() {
this.setState({loading: false});
},
/**
* Set remote content title on AppTile
* @param {string} url Url to check for title
*/
_fetchWidgetTitle(url) {
this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => {
if (widgetPageTitle) {
this.setState({widgetPageTitle: widgetPageTitle});
}
}, (err) =>{
console.error("Failed to get page title", err);
});
},
// Widget labels to render, depending upon user permissions // Widget labels to render, depending upon user permissions
// These strings are translated at the point that they are inserted in to the DOM, in the render method // These strings are translated at the point that they are inserted in to the DOM, in the render method
_deleteWidgetLabel() { _deleteWidgetLabel() {
@ -213,15 +344,15 @@ export default React.createClass({
this.setState({hasPermissionToLoad: false}); this.setState({hasPermissionToLoad: false});
}, },
formatAppTileName: function() { formatAppTileName() {
let appTileName = "No name"; let appTileName = "No name";
if(this.props.name && this.props.name.trim()) { if (this.props.name && this.props.name.trim()) {
appTileName = this.props.name.trim(); appTileName = this.props.name.trim();
} }
return appTileName; return appTileName;
}, },
onClickMenuBar: function(ev) { onClickMenuBar(ev) {
ev.preventDefault(); ev.preventDefault();
// Ignore clicks on menu bar children // Ignore clicks on menu bar children
@ -236,7 +367,16 @@ export default React.createClass({
}); });
}, },
render: function() { _getSafeUrl() {
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
return safeWidgetUrl;
},
render() {
let appTileBody; let appTileBody;
// Don't render widget if it is in the process of being deleted // Don't render widget if it is in the process of being deleted
@ -251,36 +391,32 @@ export default React.createClass({
// a link to it. // a link to it.
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
"allow-same-origin allow-scripts allow-presentation"; "allow-same-origin allow-scripts allow-presentation";
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
if (this.props.show) { if (this.props.show) {
if (this.state.loading) { const loadingElement = (
appTileBody = (
<div className='mx_AppTileBody mx_AppLoading'> <div className='mx_AppTileBody mx_AppLoading'>
<MessageSpinner msg='Loading...' /> <MessageSpinner msg='Loading...' />
</div> </div>
); );
if (this.state.initialising) {
appTileBody = loadingElement;
} else if (this.state.hasPermissionToLoad == true) { } else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) { if (this.isMixedContent()) {
appTileBody = ( appTileBody = (
<div className="mx_AppTileBody"> <div className="mx_AppTileBody">
<AppWarning <AppWarning errorMsg="Error - Mixed content" />
errorMsg="Error - Mixed content"
/>
</div> </div>
); );
} else { } else {
appTileBody = ( appTileBody = (
<div className="mx_AppTileBody"> <div className={this.state.loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody'}>
{ this.state.loading && loadingElement }
<iframe <iframe
ref="appFrame" ref="appFrame"
src={safeWidgetUrl} src={this._getSafeUrl()}
allowFullScreen="true" allowFullScreen="true"
sandbox={sandboxFlags} sandbox={sandboxFlags}
onLoad={this._onLoaded}
></iframe> ></iframe>
</div> </div>
); );
@ -302,35 +438,50 @@ export default React.createClass({
// editing is done in scalar // editing is done in scalar
const showEditButton = Boolean(this._scalarClient && this._canUserModify()); const showEditButton = Boolean(this._scalarClient && this._canUserModify());
const deleteWidgetLabel = this._deleteWidgetLabel(); const deleteWidgetLabel = this._deleteWidgetLabel();
let deleteIcon = 'img/cancel.svg'; let deleteIcon = 'img/cancel_green.svg';
let deleteClasses = 'mx_filterFlipColor mx_AppTileMenuBarWidget'; let deleteClasses = 'mx_AppTileMenuBarWidget';
if(this._canUserModify()) { if (this._canUserModify()) {
deleteIcon = 'img/cancel-red.svg'; deleteIcon = 'img/icon-delete-pink.svg';
deleteClasses += ' mx_AppTileMenuBarWidgetDelete'; deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
} }
const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg');
return ( return (
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}> <div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}> <div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
{ this.formatAppTileName() } <span className="mx_AppTileMenuBarTitle">
<TintableSvgButton
src={windowStateIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Minimize apps')}
width="10"
height="10"
/>
<b>{ this.formatAppTileName() }</b>
{ this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && (
<span>&nbsp;-&nbsp;{ this.state.widgetPageTitle }</span>
) }
</span>
<span className="mx_AppTileMenuBarWidgets"> <span className="mx_AppTileMenuBarWidgets">
{ /* Edit widget */ } { /* Edit widget */ }
{ showEditButton && <img { showEditButton && <TintableSvgButton
src="img/edit.svg" src="img/edit_green.svg"
className="mx_filterFlipColor mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding" className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
width="8" height="8"
alt={_t('Edit')}
title={_t('Edit')} title={_t('Edit')}
onClick={this._onEditClick} onClick={this._onEditClick}
width="10"
height="10"
/> } /> }
{ /* Delete widget */ } { /* Delete widget */ }
<img src={deleteIcon} <TintableSvgButton
src={deleteIcon}
className={deleteClasses} className={deleteClasses}
width="8" height="8"
alt={_t(deleteWidgetLabel)}
title={_t(deleteWidgetLabel)} title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick} onClick={this._onDeleteClick}
width="10"
height="10"
/> />
</span> </span>
</div> </div>

View file

@ -0,0 +1,85 @@
/* eslint new-cap: "off" */
/*
Copyright 2017 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { DragSource, DropTarget } from 'react-dnd';
import TagTile from './TagTile';
import dis from '../../../dispatcher';
import { findDOMNode } from 'react-dom';
const tagTileSource = {
canDrag: function(props, monitor) {
return true;
},
beginDrag: function(props) {
// Return the data describing the dragged item
return {
tag: props.tag,
};
},
endDrag: function(props, monitor, component) {
const dropResult = monitor.getDropResult();
if (!monitor.didDrop() || !dropResult) {
return;
}
props.onEndDrag();
},
};
const tagTileTarget = {
canDrop(props, monitor) {
return true;
},
hover(props, monitor, component) {
if (!monitor.canDrop()) return;
const draggedY = monitor.getClientOffset().y;
const {top, bottom} = findDOMNode(component).getBoundingClientRect();
const targetY = (top + bottom) / 2;
dis.dispatch({
action: 'order_tag',
tag: monitor.getItem().tag,
targetTag: props.tag,
// Note: we indicate that the tag should be after the target when
// it's being dragged over the top half of the target.
after: draggedY < targetY,
});
},
drop(props) {
// Return the data to be returned by getDropResult
return {
tag: props.tag,
};
},
};
export default
DropTarget('TagTile', tagTileTarget, (connect, monitor) => ({
connectDropTarget: connect.dropTarget(),
}))(DragSource('TagTile', tagTileSource, (connect, monitor) => ({
connectDragSource: connect.dragSource(),
}))((props) => {
const { connectDropTarget, connectDragSource, ...otherProps } = props;
return connectDropTarget(connectDragSource(
<div>
<TagTile {...otherProps} />
</div>,
));
}));

View file

@ -26,11 +26,9 @@ class MenuOption extends React.Component {
this._onClick = this._onClick.bind(this); this._onClick = this._onClick.bind(this);
} }
getDefaultProps() { static defaultProps = {
return {
disabled: false, disabled: false,
}; };
}
_onMouseEnter() { _onMouseEnter() {
this.props.onMouseEnter(this.props.dropdownKey); this.props.onMouseEnter(this.props.dropdownKey);

View file

@ -84,7 +84,9 @@ module.exports = React.createClass({
onNewItemChanged: PropTypes.func, onNewItemChanged: PropTypes.func,
onItemAdded: PropTypes.func, onItemAdded: PropTypes.func,
onItemEdited: PropTypes.func, onItemEdited: PropTypes.func,
onItemRemoved: PropTypes. func, onItemRemoved: PropTypes.func,
canEdit: PropTypes.bool,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -136,6 +138,7 @@ module.exports = React.createClass({
{ label } { label }
</div> </div>
{ editableItems } { editableItems }
{ this.props.canEdit ?
<EditableItem <EditableItem
key={-1} key={-1}
initialValue={this.props.newItem} initialValue={this.props.newItem}
@ -143,7 +146,8 @@ module.exports = React.createClass({
onChange={this.onNewItemChanged} onChange={this.onNewItemChanged}
addOnChange={true} addOnChange={true}
placeholder={this.props.placeholder} placeholder={this.props.placeholder}
/> /> : <div />
}
</div>); </div>);
}, },
}); });

View file

@ -19,124 +19,9 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {MatrixClient} from 'matrix-js-sdk'; import {MatrixClient} from 'matrix-js-sdk';
import UserSettingsStore from '../../../UserSettingsStore'; import FlairStore from '../../../stores/FlairStore';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import Promise from 'bluebird';
const BULK_REQUEST_DEBOUNCE_MS = 200;
// Does the server support groups? Assume yes until we receive M_UNRECOGNIZED.
// If true, flair can function and we should keep sending requests for groups and avatars.
let groupSupport = true;
const USER_GROUPS_CACHE_BUST_MS = 1800000; // 30 mins
const GROUP_PROFILES_CACHE_BUST_MS = 1800000; // 30 mins
// TODO: Cache-busting based on time. (The server won't inform us of membership changes.)
// This applies to userGroups and groupProfiles. We can provide a slightly better UX by
// cache-busting when the current user joins/leaves a group.
const userGroups = {
// $userId: ['+group1:domain', '+group2:domain', ...]
};
const groupProfiles = {
// $groupId: {
// avatar_url: 'mxc://...'
// }
};
// Represents all unsettled promises to retrieve the groups for each userId. When a promise
// is settled, it is deleted from this object.
const usersPending = {
// $userId: {
// prom: Promise
// resolve: () => {}
// reject: () => {}
// }
};
let debounceTimeoutID;
function getPublicisedGroupsCached(matrixClient, userId) {
if (userGroups[userId]) {
return Promise.resolve(userGroups[userId]);
}
// Bulk lookup ongoing, return promise to resolve/reject
if (usersPending[userId]) {
return usersPending[userId].prom;
}
usersPending[userId] = {};
usersPending[userId].prom = new Promise((resolve, reject) => {
usersPending[userId].resolve = resolve;
usersPending[userId].reject = reject;
}).then((groups) => {
userGroups[userId] = groups;
setTimeout(() => {
delete userGroups[userId];
}, USER_GROUPS_CACHE_BUST_MS);
return userGroups[userId];
}).catch((err) => {
throw err;
}).finally(() => {
delete usersPending[userId];
});
// This debounce will allow consecutive requests for the public groups of users that
// are sent in intervals of < BULK_REQUEST_DEBOUNCE_MS to be batched and only requested
// when no more requests are received within the next BULK_REQUEST_DEBOUNCE_MS. The naive
// implementation would do a request that only requested the groups for `userId`, leading
// to a worst and best case of 1 user per request. This implementation's worst is still
// 1 user per request but only if the requests are > BULK_REQUEST_DEBOUNCE_MS apart and the
// best case is N users per request.
//
// This is to reduce the number of requests made whilst trading off latency when viewing
// a Flair component.
if (debounceTimeoutID) clearTimeout(debounceTimeoutID);
debounceTimeoutID = setTimeout(() => {
batchedGetPublicGroups(matrixClient);
}, BULK_REQUEST_DEBOUNCE_MS);
return usersPending[userId].prom;
}
async function batchedGetPublicGroups(matrixClient) {
// Take the userIds from the keys of usersPending
const usersInFlight = Object.keys(usersPending);
let resp = {
users: [],
};
try {
resp = await matrixClient.getPublicisedGroups(usersInFlight);
} catch (err) {
// Propagate the same error to all usersInFlight
usersInFlight.forEach((userId) => {
usersPending[userId].reject(err);
});
return;
}
const updatedUserGroups = resp.users;
usersInFlight.forEach((userId) => {
usersPending[userId].resolve(updatedUserGroups[userId] || []);
});
}
async function getGroupProfileCached(matrixClient, groupId) {
if (groupProfiles[groupId]) {
return groupProfiles[groupId];
}
const profile = await matrixClient.getGroupProfile(groupId);
groupProfiles[groupId] = {
groupId,
avatarUrl: profile.avatar_url,
};
setTimeout(() => {
delete groupProfiles[groupId];
}, GROUP_PROFILES_CACHE_BUST_MS);
return groupProfiles[groupId];
}
class FlairAvatar extends React.Component { class FlairAvatar extends React.Component {
constructor() { constructor() {
@ -156,19 +41,23 @@ class FlairAvatar extends React.Component {
render() { render() {
const httpUrl = this.context.matrixClient.mxcUrlToHttp( const httpUrl = this.context.matrixClient.mxcUrlToHttp(
this.props.groupProfile.avatarUrl, 14, 14, 'scale', false); this.props.groupProfile.avatarUrl, 16, 16, 'scale', false);
const tooltip = this.props.groupProfile.name ?
`${this.props.groupProfile.name} (${this.props.groupProfile.groupId})`:
this.props.groupProfile.groupId;
return <img return <img
src={httpUrl} src={httpUrl}
width="14px" width="16"
height="14px" height="16"
onClick={this.onClick} onClick={this.onClick}
title={this.props.groupProfile.groupId} />; title={tooltip} />;
} }
} }
FlairAvatar.propTypes = { FlairAvatar.propTypes = {
groupProfile: PropTypes.shape({ groupProfile: PropTypes.shape({
groupId: PropTypes.string.isRequired, groupId: PropTypes.string.isRequired,
name: PropTypes.string,
avatarUrl: PropTypes.string.isRequired, avatarUrl: PropTypes.string.isRequired,
}), }),
}; };
@ -183,26 +72,19 @@ export default class Flair extends React.Component {
this.state = { this.state = {
profiles: [], profiles: [],
}; };
this.onRoomStateEvents = this.onRoomStateEvents.bind(this);
} }
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this._unmounted = true;
this.context.matrixClient.removeListener('RoomState.events', this.onRoomStateEvents);
} }
componentWillMount() { componentWillMount() {
this._unmounted = false; this._unmounted = false;
if (UserSettingsStore.isFeatureEnabled('feature_groups') && groupSupport) { this._generateAvatars(this.props.groups);
this._generateAvatars();
}
this.context.matrixClient.on('RoomState.events', this.onRoomStateEvents);
} }
onRoomStateEvents(event) { componentWillReceiveProps(newProps) {
if (event.getType() === 'm.room.related_groups' && groupSupport) { this._generateAvatars(newProps.groups);
this._generateAvatars();
}
} }
async _getGroupProfiles(groups) { async _getGroupProfiles(groups) {
@ -210,7 +92,7 @@ export default class Flair extends React.Component {
for (const groupId of groups) { for (const groupId of groups) {
let groupProfile = null; let groupProfile = null;
try { try {
groupProfile = await getGroupProfileCached(this.context.matrixClient, groupId); groupProfile = await FlairStore.getGroupProfileCached(this.context.matrixClient, groupId);
} catch (err) { } catch (err) {
console.error('Could not get profile for group', groupId, err); console.error('Could not get profile for group', groupId, err);
} }
@ -219,41 +101,13 @@ export default class Flair extends React.Component {
return profiles.filter((p) => p !== null); return profiles.filter((p) => p !== null);
} }
async _generateAvatars() { async _generateAvatars(groups) {
let groups;
try {
groups = await getPublicisedGroupsCached(this.context.matrixClient, this.props.userId);
} catch (err) {
// Indicate whether the homeserver supports groups
if (err.errcode === 'M_UNRECOGNIZED') {
console.warn('Cannot display flair, server does not support groups');
groupSupport = false;
// Return silently to avoid spamming for non-supporting servers
return;
}
console.error('Could not get groups for user', this.props.userId, err);
}
if (this.props.roomId && this.props.showRelated) {
const relatedGroupsEvent = this.context.matrixClient
.getRoom(this.props.roomId)
.currentState
.getStateEvents('m.room.related_groups', '');
const relatedGroups = relatedGroupsEvent ?
relatedGroupsEvent.getContent().groups || [] : [];
if (relatedGroups && relatedGroups.length > 0) {
groups = groups.filter((groupId) => {
return relatedGroups.includes(groupId);
});
} else {
groups = [];
}
}
if (!groups || groups.length === 0) { if (!groups || groups.length === 0) {
return; return;
} }
const profiles = await this._getGroupProfiles(groups); const profiles = await this._getGroupProfiles(groups);
if (!this.unmounted) { if (!this.unmounted) {
this.setState({profiles}); this.setState({profiles: profiles.filter((profile) => {return profile.avatarUrl;})});
} }
} }
@ -273,13 +127,7 @@ export default class Flair extends React.Component {
} }
Flair.propTypes = { Flair.propTypes = {
userId: PropTypes.string, groups: PropTypes.arrayOf(PropTypes.string),
// Whether to show only the flair associated with related groups of the given room,
// or all flair associated with a user.
showRelated: PropTypes.bool,
// The room that this flair will be displayed in. Optional. Only applies when showRelated = true.
roomId: PropTypes.string,
}; };
// TODO: We've decided that all components should follow this pattern, which means removing withMatrixClient and using // TODO: We've decided that all components should follow this pattern, which means removing withMatrixClient and using

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 sdk from '../../../index'; import sdk from '../../../index';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -22,7 +23,7 @@ const GroupsButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton'); const ActionButton = sdk.getComponent('elements.ActionButton');
return ( return (
<ActionButton action="view_my_groups" <ActionButton action="view_my_groups"
label={_t("Groups")} label={_t("Communities")}
iconPath="img/icons-groups.svg" iconPath="img/icons-groups.svg"
size={props.size} size={props.size}
tooltip={props.tooltip} tooltip={props.tooltip}

View file

@ -18,8 +18,8 @@ limitations under the License.
import React from 'react'; import React from 'react';
import sdk from '../../../index'; import sdk from '../../../index';
import UserSettingsStore from '../../../UserSettingsStore';
import * as languageHandler from '../../../languageHandler'; import * as languageHandler from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
function languageMatchesSearchQuery(query, language) { function languageMatchesSearchQuery(query, language) {
if (language.label.toUpperCase().indexOf(query.toUpperCase()) == 0) return true; if (language.label.toUpperCase().indexOf(query.toUpperCase()) == 0) return true;
@ -41,8 +41,8 @@ export default class LanguageDropdown extends React.Component {
componentWillMount() { componentWillMount() {
languageHandler.getAllLanguagesFromJson().then((langs) => { languageHandler.getAllLanguagesFromJson().then((langs) => {
langs.sort(function(a, b) { langs.sort(function(a, b) {
if(a.label < b.label) return -1; if (a.label < b.label) return -1;
if(a.label > b.label) return 1; if (a.label > b.label) return 1;
return 0; return 0;
}); });
this.setState({langs}); this.setState({langs});
@ -54,10 +54,10 @@ export default class LanguageDropdown extends React.Component {
// If no value is given, we start with the first // If no value is given, we start with the first
// country selected, but our parent component // country selected, but our parent component
// doesn't know this, therefore we do this. // doesn't know this, therefore we do this.
const _localSettings = UserSettingsStore.getLocalSettings(); const language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
if (_localSettings.hasOwnProperty('language')) { if (language) {
this.props.onOptionChange(_localSettings.language); this.props.onOptionChange(language);
}else { } else {
const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser()); const language = languageHandler.normalizeLanguageKey(languageHandler.getLanguageFromBrowser());
this.props.onOptionChange(language); this.props.onOptionChange(language);
} }
@ -95,12 +95,12 @@ export default class LanguageDropdown extends React.Component {
// default value here too, otherwise we need to handle null / undefined // default value here too, otherwise we need to handle null / undefined
// values between mounting and the initial value propgating // values between mounting and the initial value propgating
let language = SettingsStore.getValue("language", null, /*excludeDefault:*/true);
let value = null; let value = null;
const _localSettings = UserSettingsStore.getLocalSettings(); if (language) {
if (_localSettings.hasOwnProperty('language')) { value = this.props.value || language;
value = this.props.value || _localSettings.language;
} else { } else {
const language = navigator.language || navigator.userLanguage; language = navigator.language || navigator.userLanguage;
value = this.props.value || language; value = this.props.value || language;
} }

View file

@ -86,7 +86,6 @@ module.exports = React.createClass({
const summaries = orderedTransitionSequences.map((transitions) => { const summaries = orderedTransitionSequences.map((transitions) => {
const userNames = eventAggregates[transitions]; const userNames = eventAggregates[transitions];
const nameList = this._renderNameList(userNames); const nameList = this._renderNameList(userNames);
const plural = userNames.length > 1;
const splitTransitions = transitions.split(','); const splitTransitions = transitions.split(',');
@ -101,13 +100,13 @@ module.exports = React.createClass({
const descs = coalescedTransitions.map((t) => { const descs = coalescedTransitions.map((t) => {
return this._getDescriptionForTransition( return this._getDescriptionForTransition(
t.transitionType, plural, t.repeats, t.transitionType, userNames.length, t.repeats,
); );
}); });
const desc = this._renderCommaSeparatedList(descs); const desc = this._renderCommaSeparatedList(descs);
return nameList + " " + desc; return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc });
}); });
if (!summaries) { if (!summaries) {
@ -208,148 +207,75 @@ module.exports = React.createClass({
* For a certain transition, t, describe what happened to the users that * For a certain transition, t, describe what happened to the users that
* underwent the transition. * underwent the transition.
* @param {string} t the transition type. * @param {string} t the transition type.
* @param {boolean} plural whether there were multiple users undergoing the same * @param {integer} userCount number of usernames
* transition.
* @param {number} repeats the number of times the transition was repeated in a row. * @param {number} repeats the number of times the transition was repeated in a row.
* @returns {string} the written Human Readable equivalent of the transition. * @returns {string} the written Human Readable equivalent of the transition.
*/ */
_getDescriptionForTransition(t, plural, repeats) { _getDescriptionForTransition(t, userCount, repeats) {
// The empty interpolations 'severalUsers' and 'oneUser' // The empty interpolations 'severalUsers' and 'oneUser'
// are there only to show translators to non-English languages // are there only to show translators to non-English languages
// that the verb is conjugated to plural or singular Subject. // that the verb is conjugated to plural or singular Subject.
let res = null; let res = null;
switch(t) { switch (t) {
case "joined": case "joined":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)sjoined %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)sjoined %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)sjoined %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)sjoined %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)sjoined", { severalUsers: "" })
: _t("%(oneUser)sjoined", { oneUser: "" });
}
break; break;
case "left": case "left":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)sleft %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)sleft %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)sleft %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)sleft %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)sleft", { severalUsers: "" })
: _t("%(oneUser)sleft", { oneUser: "" });
}
break; break;
case "joined_and_left": case "joined_and_left":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)sjoined and left %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)sjoined and left %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)sjoined and left %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)sjoined and left %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)sjoined and left", { severalUsers: "" })
: _t("%(oneUser)sjoined and left", { oneUser: "" });
}
break; break;
case "left_and_joined": case "left_and_joined":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)sleft and rejoined %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)sleft and rejoined %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)sleft and rejoined %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)sleft and rejoined %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)sleft and rejoined", { severalUsers: "" })
: _t("%(oneUser)sleft and rejoined", { oneUser: "" });
}
break; break;
case "invite_reject": case "invite_reject":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)srejected their invitations %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)srejected their invitation %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)srejected their invitations", { severalUsers: "" })
: _t("%(oneUser)srejected their invitation", { oneUser: "" });
}
break; break;
case "invite_withdrawal": case "invite_withdrawal":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)shad their invitations withdrawn %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)shad their invitation withdrawn %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)shad their invitations withdrawn", { severalUsers: "" })
: _t("%(oneUser)shad their invitation withdrawn", { oneUser: "" });
}
break; break;
case "invited": case "invited":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("were invited %(count)s times", { count: repeats })
? _t("were invited %(repeats)s times", { repeats: repeats }) : _t("was invited %(count)s times", { count: repeats });
: _t("was invited %(repeats)s times", { repeats: repeats });
} else {
res = (plural)
? _t("were invited")
: _t("was invited");
}
break; break;
case "banned": case "banned":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("were banned %(count)s times", { count: repeats })
? _t("were banned %(repeats)s times", { repeats: repeats }) : _t("was banned %(count)s times", { count: repeats });
: _t("was banned %(repeats)s times", { repeats: repeats });
} else {
res = (plural)
? _t("were banned")
: _t("was banned");
}
break; break;
case "unbanned": case "unbanned":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("were unbanned %(count)s times", { count: repeats })
? _t("were unbanned %(repeats)s times", { repeats: repeats }) : _t("was unbanned %(count)s times", { count: repeats });
: _t("was unbanned %(repeats)s times", { repeats: repeats });
} else {
res = (plural)
? _t("were unbanned")
: _t("was unbanned");
}
break; break;
case "kicked": case "kicked":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("were kicked %(count)s times", { count: repeats })
? _t("were kicked %(repeats)s times", { repeats: repeats }) : _t("was kicked %(count)s times", { count: repeats });
: _t("was kicked %(repeats)s times", { repeats: repeats });
} else {
res = (plural)
? _t("were kicked")
: _t("was kicked");
}
break; break;
case "changed_name": case "changed_name":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)schanged their name %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)schanged their name %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)schanged their name %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)schanged their name %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)schanged their name", { severalUsers: "" })
: _t("%(oneUser)schanged their name", { oneUser: "" });
}
break; break;
case "changed_avatar": case "changed_avatar":
if (repeats > 1) { res = (userCount > 1)
res = (plural) ? _t("%(severalUsers)schanged their avatar %(count)s times", { severalUsers: "", count: repeats })
? _t("%(severalUsers)schanged their avatar %(repeats)s times", { severalUsers: "", repeats: repeats }) : _t("%(oneUser)schanged their avatar %(count)s times", { oneUser: "", count: repeats });
: _t("%(oneUser)schanged their avatar %(repeats)s times", { oneUser: "", repeats: repeats });
} else {
res = (plural)
? _t("%(severalUsers)schanged their avatar", { severalUsers: "" })
: _t("%(oneUser)schanged their avatar", { oneUser: "" });
}
break; break;
} }
@ -376,11 +302,9 @@ module.exports = React.createClass({
return ""; return "";
} else if (items.length === 1) { } else if (items.length === 1) {
return items[0]; return items[0];
} else if (remaining) { } else if (remaining > 0) {
items = items.slice(0, itemLimit); items = items.slice(0, itemLimit);
return (remaining > 1) return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } );
? _t("%(items)s and %(remaining)s others", { items: items.join(', '), remaining: remaining } )
: _t("%(items)s and one other", { items: items.join(', ') });
} else { } else {
const lastItem = items.pop(); const lastItem = items.pop();
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
@ -554,7 +478,7 @@ module.exports = React.createClass({
} }
const toggleButton = ( const toggleButton = (
<div className={"mx_MemberEventListSummary_toggle"} onClick={this._toggleSummary}> <div className={"mx_MemberEventListSummary_toggle"} onClick={this._toggleSummary}>
{ expanded ? 'collapse' : 'expand' } { expanded ? _t('collapse') : _t('expand') }
</div> </div>
); );

View file

@ -37,11 +37,20 @@ const Pill = React.createClass({
isMessagePillUrl: (url) => { isMessagePillUrl: (url) => {
return !!REGEX_LOCAL_MATRIXTO.exec(url); return !!REGEX_LOCAL_MATRIXTO.exec(url);
}, },
roomNotifPos: (text) => {
return text.indexOf("@room");
},
roomNotifLen: () => {
return "@room".length;
},
TYPE_USER_MENTION: 'TYPE_USER_MENTION', TYPE_USER_MENTION: 'TYPE_USER_MENTION',
TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION', TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION',
TYPE_AT_ROOM_MENTION: 'TYPE_AT_ROOM_MENTION', // '@room' mention
}, },
props: { props: {
// The Type of this Pill. If url is given, this is auto-detected.
type: PropTypes.string,
// The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl) // The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl)
url: PropTypes.string, url: PropTypes.string,
// Whether the pill is in a message // Whether the pill is in a message
@ -72,14 +81,20 @@ const Pill = React.createClass({
regex = REGEX_LOCAL_MATRIXTO; regex = REGEX_LOCAL_MATRIXTO;
} }
let matrixToMatch;
let resourceId;
let prefix;
if (nextProps.url) {
// Default to the empty array if no match for simplicity // Default to the empty array if no match for simplicity
// resource and prefix will be undefined instead of throwing // resource and prefix will be undefined instead of throwing
const matrixToMatch = regex.exec(nextProps.url) || []; matrixToMatch = regex.exec(nextProps.url) || [];
const resourceId = matrixToMatch[1]; // The room/user ID resourceId = matrixToMatch[1]; // The room/user ID
const prefix = matrixToMatch[2]; // The first character of prefix prefix = matrixToMatch[2]; // The first character of prefix
}
const pillType = { const pillType = this.props.type || {
'@': Pill.TYPE_USER_MENTION, '@': Pill.TYPE_USER_MENTION,
'#': Pill.TYPE_ROOM_MENTION, '#': Pill.TYPE_ROOM_MENTION,
'!': Pill.TYPE_ROOM_MENTION, '!': Pill.TYPE_ROOM_MENTION,
@ -88,6 +103,10 @@ const Pill = React.createClass({
let member; let member;
let room; let room;
switch (pillType) { switch (pillType) {
case Pill.TYPE_AT_ROOM_MENTION: {
room = nextProps.room;
}
break;
case Pill.TYPE_USER_MENTION: { case Pill.TYPE_USER_MENTION: {
const localMember = nextProps.room.getMember(resourceId); const localMember = nextProps.room.getMember(resourceId);
member = localMember; member = localMember;
@ -160,6 +179,17 @@ const Pill = React.createClass({
let href = this.props.url; let href = this.props.url;
let onClick; let onClick;
switch (this.state.pillType) { switch (this.state.pillType) {
case Pill.TYPE_AT_ROOM_MENTION: {
const room = this.props.room;
if (room) {
linkText = "@room";
if (this.props.shouldShowPillAvatar) {
avatar = <RoomAvatar room={room} width={16} height={16} />;
}
pillClass = 'mx_AtRoomPill';
}
}
break;
case Pill.TYPE_USER_MENTION: { case Pill.TYPE_USER_MENTION: {
// If this user is not a member of this room, default to the empty member // If this user is not a member of this room, default to the empty member
const member = this.state.member; const member = this.state.member;

View file

@ -20,14 +20,16 @@ import React from 'react';
import * as Roles from '../../../Roles'; import * as Roles from '../../../Roles';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
let LEVEL_ROLE_MAP = {};
const reverseRoles = {};
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'PowerSelector', displayName: 'PowerSelector',
propTypes: { propTypes: {
value: React.PropTypes.number.isRequired, value: React.PropTypes.number.isRequired,
// The maximum value that can be set with the power selector
maxValue: React.PropTypes.number.isRequired,
// Default user power level for the room
usersDefault: React.PropTypes.number.isRequired,
// if true, the <select/> should be a 'controlled' form element and updated by React // if true, the <select/> should be a 'controlled' form element and updated by React
// to reflect the current value, rather than left freeform. // to reflect the current value, rather than left freeform.
@ -43,78 +45,98 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
custom: (LEVEL_ROLE_MAP[this.props.value] === undefined), levelRoleMap: {},
// List of power levels to show in the drop-down
options: [],
};
},
getDefaultProps: function() {
return {
maxValue: Infinity,
usersDefault: 0,
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
LEVEL_ROLE_MAP = Roles.levelRoleMap(); this._initStateFromProps(this.props);
Object.keys(LEVEL_ROLE_MAP).forEach(function(key) { },
reverseRoles[LEVEL_ROLE_MAP[key]] = key;
componentWillReceiveProps: function(newProps) {
this._initStateFromProps(newProps);
},
_initStateFromProps: function(newProps) {
// This needs to be done now because levelRoleMap has translated strings
const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault);
const options = Object.keys(levelRoleMap).filter((l) => {
return l === undefined || l <= newProps.maxValue;
});
this.setState({
levelRoleMap,
options,
custom: levelRoleMap[newProps.value] === undefined,
}); });
}, },
onSelectChange: function(event) { onSelectChange: function(event) {
this.setState({ custom: event.target.value === "Custom" }); this.setState({ custom: event.target.value === "SELECT_VALUE_CUSTOM" });
if (event.target.value !== "Custom") { if (event.target.value !== "SELECT_VALUE_CUSTOM") {
this.props.onChange(this.getValue()); this.props.onChange(event.target.value);
} }
}, },
onCustomBlur: function(event) { onCustomBlur: function(event) {
this.props.onChange(this.getValue()); this.props.onChange(parseInt(this.refs.custom.value));
}, },
onCustomKeyDown: function(event) { onCustomKeyDown: function(event) {
if (event.key == "Enter") { if (event.key == "Enter") {
this.props.onChange(this.getValue()); this.props.onChange(parseInt(this.refs.custom.value));
} }
}, },
getValue: function() {
let value;
if (this.refs.select) {
value = reverseRoles[this.refs.select.value];
if (this.refs.custom) {
if (value === undefined) value = parseInt( this.refs.custom.value );
}
}
return value;
},
render: function() { render: function() {
let customPicker; let customPicker;
if (this.state.custom) { if (this.state.custom) {
let input;
if (this.props.disabled) { if (this.props.disabled) {
input = <span>{ this.props.value }</span>; customPicker = <span>{ _t(
"Custom of %(powerLevel)s",
{ powerLevel: this.props.value },
) }</span>;
} else { } else {
input = <input ref="custom" type="text" size="3" defaultValue={this.props.value} onBlur={this.onCustomBlur} onKeyDown={this.onCustomKeyDown} />; customPicker = <span> = <input
ref="custom"
type="text"
size="3"
defaultValue={this.props.value}
onBlur={this.onCustomBlur}
onKeyDown={this.onCustomKeyDown}
/>
</span>;
} }
customPicker = <span> of { input }</span>;
} }
let selectValue; let selectValue;
if (this.state.custom) { if (this.state.custom) {
selectValue = "Custom"; selectValue = "SELECT_VALUE_CUSTOM";
} else { } else {
selectValue = LEVEL_ROLE_MAP[this.props.value] || "Custom"; selectValue = this.state.levelRoleMap[this.props.value] ?
this.props.value : "SELECT_VALUE_CUSTOM";
} }
let select; let select;
if (this.props.disabled) { if (this.props.disabled) {
select = <span>{ selectValue }</span>; select = <span>{ this.state.levelRoleMap[selectValue] }</span>;
} else { } else {
// Each level must have a definition in LEVEL_ROLE_MAP // Each level must have a definition in this.state.levelRoleMap
const levels = [0, 50, 100]; let options = this.state.options.map((level) => {
let options = levels.map((level) => {
return { return {
value: LEVEL_ROLE_MAP[level], value: level,
// Give a userDefault (users_default in the power event) of 0 but text: Roles.textualPowerLevel(level, this.props.usersDefault),
// because level !== undefined, this should never be used.
text: Roles.textualPowerLevel(level, 0),
}; };
}); });
options.push({ value: "Custom", text: _t("Custom level") }); options.push({ value: "SELECT_VALUE_CUSTOM", text: _t("Custom level") });
options = options.map((op) => { options = options.map((op) => {
return <option value={op.value} key={op.value}>{ op.text }</option>; return <option value={op.value} key={op.value}>{ op.text }</option>;
}); });

View file

@ -0,0 +1,110 @@
/*
Copyright 2017 Travis Ralston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import SettingsStore from "../../../settings/SettingsStore";
import { _t } from '../../../languageHandler';
module.exports = React.createClass({
displayName: 'SettingsFlag',
propTypes: {
name: React.PropTypes.string.isRequired,
level: React.PropTypes.string.isRequired,
roomId: React.PropTypes.string, // for per-room settings
label: React.PropTypes.string, // untranslated
onChange: React.PropTypes.func,
isExplicit: React.PropTypes.bool,
manualSave: React.PropTypes.bool,
// If group is supplied, then this will create a radio button instead.
group: React.PropTypes.string,
value: React.PropTypes.any, // the value for the radio button
},
getInitialState: function() {
return {
value: SettingsStore.getValueAt(
this.props.level,
this.props.name,
this.props.roomId,
this.props.isExplicit,
),
};
},
onChange: function(e) {
if (this.props.group && !e.target.checked) return;
const newState = this.props.group ? this.props.value : e.target.checked;
if (!this.props.manualSave) this.save(newState);
else this.setState({ value: newState });
if (this.props.onChange) this.props.onChange(newState);
},
save: function(val = undefined) {
return SettingsStore.setValue(
this.props.name,
this.props.roomId,
this.props.level,
val !== undefined ? val : this.state.value,
);
},
render: function() {
const value = this.props.manualSave ? this.state.value : SettingsStore.getValueAt(
this.props.level,
this.props.name,
this.props.roomId,
this.props.isExplicit,
);
const canChange = SettingsStore.canSetValue(this.props.name, this.props.roomId, this.props.level);
let label = this.props.label;
if (!label) label = SettingsStore.getDisplayName(this.props.name, this.props.level);
else label = _t(label);
// We generate a relatively complex ID to avoid conflicts
const id = this.props.name + "_" + this.props.group + "_" + this.props.value + "_" + this.props.level;
let checkbox = (
<input id={id}
type="checkbox"
defaultChecked={value}
onChange={this.onChange}
disabled={!canChange}
/>
);
if (this.props.group) {
checkbox = (
<input id={id}
type="radio"
name={this.props.group}
value={this.props.value}
checked={value === this.props.value}
onChange={this.onChange}
disabled={!canChange}
/>
);
}
return (
<label>
{ checkbox }
{ label }
</label>
);
},
});

View file

@ -0,0 +1,119 @@
/*
Copyright 2017 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index';
import dis from '../../../dispatcher';
import { isOnlyCtrlOrCmdKeyEvent } from '../../../Keyboard';
import FlairStore from '../../../stores/FlairStore';
// A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents
// a thing to click on for the user to filter the visible rooms in the RoomList to:
// - Rooms that are part of the group
// - Direct messages with members of the group
// with the intention that this could be expanded to arbitrary tags in future.
export default React.createClass({
displayName: 'TagTile',
propTypes: {
// A string tag such as "m.favourite" or a group ID such as "+groupid:domain.bla"
// For now, only group IDs are handled.
tag: PropTypes.string,
},
contextTypes: {
matrixClient: React.PropTypes.instanceOf(MatrixClient).isRequired,
},
getInitialState() {
return {
// Whether the mouse is over the tile
hover: false,
// The profile data of the group if this.props.tag is a group ID
profile: null,
};
},
componentWillMount() {
this.unmounted = false;
if (this.props.tag[0] === '+') {
FlairStore.getGroupProfileCached(
this.context.matrixClient,
this.props.tag,
).then((profile) => {
if (this.unmounted) return;
this.setState({profile});
}).catch((err) => {
console.warn('Could not fetch group profile for ' + this.props.tag, err);
});
}
},
componentWillUnmount() {
this.unmounted = true;
},
onClick: function(e) {
e.preventDefault();
e.stopPropagation();
dis.dispatch({
action: 'select_tag',
tag: this.props.tag,
ctrlOrCmdKey: isOnlyCtrlOrCmdKeyEvent(e),
shiftKey: e.shiftKey,
});
},
onMouseOver: function() {
this.setState({hover: true});
},
onMouseOut: function() {
this.setState({hover: false});
},
render: function() {
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
const profile = this.state.profile || {};
const name = profile.name || this.props.tag;
const avatarHeight = 35;
const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp(
profile.avatarUrl, avatarHeight, avatarHeight, "crop",
) : null;
const className = classNames({
mx_TagTile: true,
mx_TagTile_selected: this.props.selected,
});
const tip = this.state.hover ?
<RoomTooltip className="mx_TagTile_tooltip" label={name} /> :
<div />;
return <AccessibleButton className={className} onClick={this.onClick}>
<div className="mx_TagTile_avatar" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut}>
<BaseAvatar name={name} url={httpUrl} width={avatarHeight} height={avatarHeight} />
{ tip }
</div>
</AccessibleButton>;
},
});

View file

@ -0,0 +1,61 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import TintableSvg from './TintableSvg';
export default class TintableSvgButton extends React.Component {
constructor(props) {
super(props);
}
render() {
let classes = "mx_TintableSvgButton";
if (this.props.className) {
classes += " " + this.props.className;
}
return (
<span
width={this.props.width}
height={this.props.height}
className={classes}>
<TintableSvg
src={this.props.src}
width={this.props.width}
height={this.props.height}
></TintableSvg>
<span
title={this.props.title}
onClick={this.props.onClick} />
</span>
);
}
}
TintableSvgButton.propTypes = {
src: PropTypes.string,
title: PropTypes.string,
className: PropTypes.string,
width: PropTypes.string.isRequired,
height: PropTypes.string.isRequired,
onClick: PropTypes.func,
};
TintableSvgButton.defaultProps = {
onClick: function() {},
};

View file

@ -0,0 +1,55 @@
/*
Copyright 2017 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
module.exports = React.createClass({
displayName: 'ToolTipButton',
getInitialState: function() {
return {
hover: false,
};
},
onMouseOver: function() {
this.setState({
hover: true,
});
},
onMouseOut: function() {
this.setState({
hover: false,
});
},
render: function() {
const RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
const tip = this.state.hover ? <RoomTooltip
className="mx_ToolTipButton_container"
tooltipClassName="mx_ToolTipButton_helpText"
label={this.props.helpText}
/> : <div />;
return (
<div className="mx_ToolTipButton" onMouseOver={this.onMouseOver} onMouseOut={this.onMouseOut} >
?
{ tip }
</div>
);
},
});

View file

@ -16,6 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
@ -27,6 +28,10 @@ export default React.createClass({
group: PropTypes.object.isRequired, group: PropTypes.object.isRequired,
}, },
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
onClick: function(e) { onClick: function(e) {
dis.dispatch({ dis.dispatch({
action: 'view_group', action: 'view_group',
@ -38,29 +43,29 @@ export default React.createClass({
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const EmojiText = sdk.getComponent('elements.EmojiText'); const EmojiText = sdk.getComponent('elements.EmojiText');
const av = ( const groupName = this.props.group.name || this.props.group.groupId;
<BaseAvatar name={this.props.group.name} width={24} height={24} const httpAvatarUrl = this.props.group.avatarUrl ?
url={this.props.group.avatarUrl} this.context.matrixClient.mxcUrlToHttp(this.props.group.avatarUrl, 24, 24) : null;
/>
); const av = <BaseAvatar name={groupName} width={24} height={24} url={httpAvatarUrl} />;
const label = <EmojiText const label = <EmojiText
element="div" element="div"
title={this.props.group.name} title={this.props.group.groupId}
className="mx_GroupInviteTile_name" className="mx_RoomTile_name mx_RoomTile_badgeShown"
dir="auto" dir="auto"
> >
{ this.props.group.name } { groupName }
</EmojiText>; </EmojiText>;
const badge = <div className="mx_GroupInviteTile_badge">!</div>; const badge = <div className="mx_RoomSubList_badge mx_RoomSubList_badgeHighlight">!</div>;
return ( return (
<AccessibleButton className="mx_GroupInviteTile" onClick={this.onClick}> <AccessibleButton className="mx_RoomTile mx_RoomTile_highlight" onClick={this.onClick}>
<div className="mx_GroupInviteTile_avatarContainer"> <div className="mx_RoomTile_avatar">
{ av } { av }
</div> </div>
<div className="mx_GroupInviteTile_nameContainer"> <div className="mx_RoomTile_nameContainer">
{ label } { label }
{ badge } { badge }
</div> </div>

View file

@ -17,64 +17,81 @@ limitations under the License.
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import sdk from '../../../index'; import sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { GroupMemberType } from '../../../groups'; import { GroupMemberType } from '../../../groups';
import { groupMemberFromApiObject } from '../../../groups'; import GroupStoreCache from '../../../stores/GroupStoreCache';
import withMatrixClient from '../../../wrappers/withMatrixClient';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import GeminiScrollbar from 'react-gemini-scrollbar'; import GeminiScrollbar from 'react-gemini-scrollbar';
module.exports = React.createClass({
module.exports = withMatrixClient(React.createClass({
displayName: 'GroupMemberInfo', displayName: 'GroupMemberInfo',
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
propTypes: { propTypes: {
matrixClient: PropTypes.object.isRequired,
groupId: PropTypes.string, groupId: PropTypes.string,
groupMember: GroupMemberType, groupMember: GroupMemberType,
isInvited: PropTypes.bool,
}, },
getInitialState: function() { getInitialState: function() {
return { return {
fetching: false,
removingUser: false, removingUser: false,
groupMembers: null, isUserPrivilegedInGroup: null,
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._fetchMembers(); this._initGroupStore(this.props.groupId);
}, },
_fetchMembers: function() { componentWillReceiveProps(newProps) {
this.setState({fetching: true}); if (newProps.groupId !== this.props.groupId) {
this.props.matrixClient.getGroupUsers(this.props.groupId).then((result) => { this._unregisterGroupStore();
this._initGroupStore(newProps.groupId);
}
},
_initGroupStore(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(this.props.groupId);
this._groupStore.registerListener(this.onGroupStoreUpdated);
},
_unregisterGroupStore() {
if (this._groupStore) {
this._groupStore.unregisterListener(this.onGroupStoreUpdated);
}
},
onGroupStoreUpdated: function() {
this.setState({ this.setState({
groupMembers: result.chunk.map((apiMember) => { isUserInvited: this._groupStore.getGroupInvitedMembers().some(
return groupMemberFromApiObject(apiMember); (m) => m.userId === this.props.groupMember.userId,
}), ),
fetching: false, isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(),
});
}).catch((e) => {
this.setState({fetching: false});
console.error("Failed to get group groupMember list: ", e);
}); });
}, },
_onKick: function() { _onKick: function() {
const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
Modal.createDialog(ConfirmUserActionDialog, { Modal.createDialog(ConfirmUserActionDialog, {
matrixClient: this.context.matrixClient,
groupMember: this.props.groupMember, groupMember: this.props.groupMember,
action: _t('Remove from group'), action: this.state.isUserInvited ? _t('Disinvite') : _t('Remove from community'),
title: this.state.isUserInvited ? _t('Disinvite this user from community?')
: _t('Remove this user from community?'),
danger: true, danger: true,
onFinished: (proceed) => { onFinished: (proceed) => {
if (!proceed) return; if (!proceed) return;
this.setState({removingUser: true}); this.setState({removingUser: true});
this.props.matrixClient.removeUserFromGroup( this.context.matrixClient.removeUserFromGroup(
this.props.groupId, this.props.groupMember.userId, this.props.groupId, this.props.groupMember.userId,
).then(() => { ).then(() => {
// return to the user list // return to the user list
@ -86,7 +103,9 @@ module.exports = withMatrixClient(React.createClass({
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, { Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, {
title: _t('Error'), title: _t('Error'),
description: _t('Failed to remove user from group'), description: this.state.isUserInvited ?
_t('Failed to withdraw invitation') :
_t('Failed to remove user from community'),
}); });
}).finally(() => { }).finally(() => {
this.setState({removingUser: false}); this.setState({removingUser: false});
@ -111,24 +130,17 @@ module.exports = withMatrixClient(React.createClass({
}, },
render: function() { render: function() {
if (this.state.fetching || this.state.removingUser) { if (this.state.removingUser) {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />; return <Spinner />;
} }
if (!this.state.groupMembers) return null;
const targetIsInGroup = this.state.groupMembers.some((m) => { let adminTools;
return m.userId === this.props.groupMember.userId; if (this.state.isUserPrivilegedInGroup) {
}); const kickButton = (
let kickButton;
let adminButton;
if (targetIsInGroup) {
kickButton = (
<AccessibleButton className="mx_MemberInfo_field" <AccessibleButton className="mx_MemberInfo_field"
onClick={this._onKick}> onClick={this._onKick}>
{ _t('Remove from group') } { this.state.isUserInvited ? _t('Disinvite') : _t('Remove from community') }
</AccessibleButton> </AccessibleButton>
); );
@ -137,22 +149,19 @@ module.exports = withMatrixClient(React.createClass({
giveModButton = <AccessibleButton className="mx_MemberInfo_field" onClick={this.onModToggle}> giveModButton = <AccessibleButton className="mx_MemberInfo_field" onClick={this.onModToggle}>
{giveOpLabel} {giveOpLabel}
</AccessibleButton>;*/ </AccessibleButton>;*/
}
let adminTools; if (kickButton) {
if (kickButton || adminButton) {
adminTools = adminTools =
<div className="mx_MemberInfo_adminTools"> <div className="mx_MemberInfo_adminTools">
<h3>{ _t("Admin Tools") }</h3> <h3>{ _t("Admin Tools") }</h3>
<div className="mx_MemberInfo_buttons"> <div className="mx_MemberInfo_buttons">
{ kickButton } { kickButton }
{ adminButton }
</div> </div>
</div>; </div>;
} }
}
const avatarUrl = this.props.matrixClient.mxcUrlToHttp( const avatarUrl = this.context.matrixClient.mxcUrlToHttp(
this.props.groupMember.avatarUrl, this.props.groupMember.avatarUrl,
36, 36, 'crop', 36, 36, 'crop',
); );
@ -173,7 +182,7 @@ module.exports = withMatrixClient(React.createClass({
<div className="mx_MemberInfo"> <div className="mx_MemberInfo">
<GeminiScrollbar autoshow={true}> <GeminiScrollbar autoshow={true}>
<AccessibleButton className="mx_MemberInfo_cancel"onClick={this._onCancel}> <AccessibleButton className="mx_MemberInfo_cancel"onClick={this._onCancel}>
<img src="img/cancel.svg" width="18" height="18" /> <img src="img/cancel.svg" width="18" height="18" className="mx_filterFlipColor" />
</AccessibleButton> </AccessibleButton>
<div className="mx_MemberInfo_avatar"> <div className="mx_MemberInfo_avatar">
{ avatar } { avatar }
@ -192,4 +201,4 @@ module.exports = withMatrixClient(React.createClass({
</div> </div>
); );
}, },
})); });

View file

@ -17,46 +17,44 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import { groupMemberFromApiObject } from '../../../groups'; import GroupStoreCache from '../../../stores/GroupStoreCache';
import GeminiScrollbar from 'react-gemini-scrollbar'; import GeminiScrollbar from 'react-gemini-scrollbar';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import withMatrixClient from '../../../wrappers/withMatrixClient';
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
export default withMatrixClient(React.createClass({ export default React.createClass({
displayName: 'GroupMemberList', displayName: 'GroupMemberList',
propTypes: { propTypes: {
matrixClient: PropTypes.object.isRequired,
groupId: PropTypes.string.isRequired, groupId: PropTypes.string.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
return { return {
fetching: false,
members: null, members: null,
invitedMembers: null,
truncateAt: INITIAL_LOAD_NUM_MEMBERS, truncateAt: INITIAL_LOAD_NUM_MEMBERS,
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false; this._unmounted = false;
this._initGroupStore(this.props.groupId);
},
_initGroupStore: function(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(groupId);
this._groupStore.registerListener(() => {
this._fetchMembers(); this._fetchMembers();
});
}, },
_fetchMembers: function() { _fetchMembers: function() {
this.setState({fetching: true}); if (this._unmounted) return;
this.props.matrixClient.getGroupUsers(this.props.groupId).then((result) => {
this.setState({ this.setState({
members: result.chunk.map((apiMember) => { members: this._groupStore.getGroupMembers(),
return groupMemberFromApiObject(apiMember); invitedMembers: this._groupStore.getGroupInvitedMembers(),
}),
fetching: false,
});
}).catch((e) => {
this.setState({fetching: false});
console.error("Failed to get group member list: " + e);
}); });
}, },
@ -83,14 +81,13 @@ export default withMatrixClient(React.createClass({
this.setState({ searchQuery: ev.target.value }); this.setState({ searchQuery: ev.target.value });
}, },
makeGroupMemberTiles: function(query) { makeGroupMemberTiles: function(query, memberList) {
const GroupMemberTile = sdk.getComponent("groups.GroupMemberTile"); const GroupMemberTile = sdk.getComponent("groups.GroupMemberTile");
const TruncatedList = sdk.getComponent("elements.TruncatedList");
query = (query || "").toLowerCase(); query = (query || "").toLowerCase();
let memberList = this.state.members;
if (query) { if (query) {
memberList = memberList.filter((m) => { memberList = memberList.filter((m) => {
const matchesName = m.displayname.toLowerCase().indexOf(query) !== -1; const matchesName = (m.displayname || "").toLowerCase().includes(query);
const matchesId = m.userId.toLowerCase().includes(query); const matchesId = m.userId.toLowerCase().includes(query);
if (!matchesName && !matchesId) { if (!matchesName && !matchesId) {
@ -101,55 +98,75 @@ export default withMatrixClient(React.createClass({
}); });
} }
memberList = memberList.map((m) => { const uniqueMembers = {};
memberList.forEach((m) => {
if (!uniqueMembers[m.userId]) uniqueMembers[m.userId] = m;
});
memberList = Object.keys(uniqueMembers).map((userId) => uniqueMembers[userId]);
// Descending sort on isPrivileged = true = 1 to isPrivileged = false = 0
memberList.sort((a, b) => {
if (a.isPrivileged === b.isPrivileged) {
const aName = a.displayname || a.userId;
const bName = b.displayname || b.userId;
if (aName < bName) {
return -1;
} else if (aName > bName) {
return 1;
} else {
return 0;
}
} else {
return a.isPrivileged ? -1 : 1;
}
});
const memberTiles = memberList.map((m) => {
return ( return (
<GroupMemberTile key={m.userId} groupId={this.props.groupId} member={m} /> <GroupMemberTile key={m.userId} groupId={this.props.groupId} member={m} />
); );
}); });
memberList.sort((a, b) => { return <TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAt}
// TODO: should put admins at the top: we don't yet have that info createOverflowElement={this._createOverflowTile}
if (a < b) { >
return -1; { memberTiles }
} else if (a > b) { </TruncatedList>;
return 1;
} else {
return 0;
}
});
return memberList;
}, },
render: function() { render: function() {
if (this.state.fetching) { if (this.state.fetching || this.state.fetchingInvitedMembers) {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
return (<div className="mx_MemberList"> return (<div className="mx_MemberList">
<Spinner /> <Spinner />
</div>); </div>);
} else if (this.state.members === null) {
return null;
} }
const inputBox = ( const inputBox = (
<form autoComplete="off"> <form autoComplete="off">
<input className="mx_GroupMemberList_query" id="mx_GroupMemberList_query" type="text" <input className="mx_GroupMemberList_query" id="mx_GroupMemberList_query" type="text"
onChange={this.onSearchQueryChanged} value={this.state.searchQuery} onChange={this.onSearchQueryChanged} value={this.state.searchQuery}
placeholder={_t('Filter group members')} /> placeholder={_t('Filter community members')} />
</form> </form>
); );
const TruncatedList = sdk.getComponent("elements.TruncatedList"); const joined = this.state.members ? <div className="mx_MemberList_joined">
{ this.makeGroupMemberTiles(this.state.searchQuery, this.state.members) }
</div> : <div />;
const invited = (this.state.invitedMembers && this.state.invitedMembers.length > 0) ?
<div className="mx_MemberList_invited">
<h2>{ _t("Invited") }</h2>
{ this.makeGroupMemberTiles(this.state.searchQuery, this.state.invitedMembers) }
</div> : <div />;
return ( return (
<div className="mx_MemberList"> <div className="mx_MemberList">
{ inputBox } { inputBox }
<GeminiScrollbar autoshow={true} className="mx_MemberList_joined mx_MemberList_outerWrapper"> <GeminiScrollbar autoshow={true} className="mx_MemberList_outerWrapper">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAt} { joined }
createOverflowElement={this._createOverflowTile}> { invited }
{ this.makeGroupMemberTiles(this.state.searchQuery) }
</TruncatedList>
</GeminiScrollbar> </GeminiScrollbar>
</div> </div>
); );
}, },
})); });

View file

@ -61,9 +61,9 @@ export default withMatrixClient(React.createClass({
); );
return ( return (
<EntityTile presenceState="online" <EntityTile name={name} avatarJsx={av} onClick={this.onClick}
avatarJsx={av} onClick={this.onClick} suppressOnHover={true} presenceState="online"
name={name} powerLevel={0} suppressOnHover={true} powerStatus={this.props.member.isPrivileged ? EntityTile.POWER_STATUS_ADMIN : null}
/> />
); );
}, },

View file

@ -0,0 +1,86 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index';
import GroupStoreCache from '../../../stores/GroupStoreCache';
import GroupStore from '../../../stores/GroupStore';
import { _t } from '../../../languageHandler.js';
export default React.createClass({
displayName: 'GroupPublicityToggle',
propTypes: {
groupId: PropTypes.string.isRequired,
},
getInitialState() {
return {
busy: false,
ready: false,
isGroupPublicised: null,
};
},
componentWillMount: function() {
this._initGroupStore(this.props.groupId);
},
_initGroupStore: function(groupId) {
this._groupStore = GroupStoreCache.getGroupStore(groupId);
this._groupStore.registerListener(() => {
this.setState({
isGroupPublicised: this._groupStore.getGroupPublicity(),
ready: this._groupStore.isStateReady(GroupStore.STATE_KEY.Summary),
});
});
},
_onPublicityToggle: function(e) {
e.stopPropagation();
this.setState({
busy: true,
// Optimistic early update
isGroupPublicised: !this.state.isGroupPublicised,
});
this._groupStore.setGroupPublicity(!this.state.isGroupPublicised).then(() => {
this.setState({
busy: false,
});
});
},
render() {
const GroupTile = sdk.getComponent('groups.GroupTile');
const input = <input type="checkbox"
onClick={this._onPublicityToggle}
checked={this.state.isGroupPublicised}
/>;
const labelText = !this.state.ready ? _t("Loading...") :
(this.state.isGroupPublicised ?
_t("Flair will appear if enabled in room settings") :
_t("Flair will not appear")
);
return <div className="mx_GroupPublicity_toggle">
<GroupTile groupId={this.props.groupId} showDescription={false} avatarHeight={40} />
<label onClick={this._onPublicityToggle}>
{ input }
{ labelText }
</label>
</div>;
},
});

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