Merge branch 'develop' into travis/remove-presence

This commit is contained in:
Travis Ralston 2018-04-11 15:17:28 -06:00
commit fe2cbc584d
246 changed files with 16884 additions and 4954 deletions

View file

@ -8,7 +8,6 @@ src/CallHandler.js
src/component-index.js src/component-index.js
src/components/structures/ContextualMenu.js src/components/structures/ContextualMenu.js
src/components/structures/CreateRoom.js src/components/structures/CreateRoom.js
src/components/structures/FilePanel.js
src/components/structures/LoggedInView.js src/components/structures/LoggedInView.js
src/components/structures/login/ForgotPassword.js src/components/structures/login/ForgotPassword.js
src/components/structures/login/Login.js src/components/structures/login/Login.js
@ -27,16 +26,10 @@ src/components/views/dialogs/ChatCreateOrReuseDialog.js
src/components/views/dialogs/DeactivateAccountDialog.js src/components/views/dialogs/DeactivateAccountDialog.js
src/components/views/dialogs/UnknownDeviceDialog.js src/components/views/dialogs/UnknownDeviceDialog.js
src/components/views/elements/AddressSelector.js src/components/views/elements/AddressSelector.js
src/components/views/elements/CreateRoomButton.js
src/components/views/elements/DeviceVerifyButtons.js src/components/views/elements/DeviceVerifyButtons.js
src/components/views/elements/DirectorySearchBox.js src/components/views/elements/DirectorySearchBox.js
src/components/views/elements/EditableText.js src/components/views/elements/EditableText.js
src/components/views/elements/HomeButton.js
src/components/views/elements/MemberEventListSummary.js src/components/views/elements/MemberEventListSummary.js
src/components/views/elements/PowerSelector.js
src/components/views/elements/RoomDirectoryButton.js
src/components/views/elements/SettingsButton.js
src/components/views/elements/StartChatButton.js
src/components/views/elements/TintableSvg.js src/components/views/elements/TintableSvg.js
src/components/views/elements/UserSelector.js src/components/views/elements/UserSelector.js
src/components/views/login/CountryDropdown.js src/components/views/login/CountryDropdown.js
@ -93,7 +86,6 @@ src/RichText.js
src/Roles.js src/Roles.js
src/Rooms.js src/Rooms.js
src/ScalarAuthClient.js src/ScalarAuthClient.js
src/Tinter.js
src/UiEffects.js src/UiEffects.js
src/Unread.js src/Unread.js
src/utils/DecryptFile.js src/utils/DecryptFile.js

View file

@ -3,7 +3,10 @@ dist: trusty
# we don't need sudo, so can run in a container, which makes startup much # we don't need sudo, so can run in a container, which makes startup much
# quicker. # quicker.
sudo: false #
# unfortunately we do temporarily require sudo as a workaround for
# https://github.com/travis-ci/travis-ci/issues/8836
sudo: required
language: node_js language: node_js
node_js: node_js:

View file

@ -1,3 +1,402 @@
Changes in [0.12.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.1) (2018-04-11)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0...v0.12.1)
Changes in [0.12.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0) (2018-04-11)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.7...v0.12.0)
* Further improve group joining/leaving feedback
[\#1832](https://github.com/matrix-org/matrix-react-sdk/pull/1832)
* Cosmetic changes to Communities button
Changes in [0.12.0-rc.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.7) (2018-04-10)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.6...v0.12.0-rc.7)
* Reword group setting delay
[\#1816](https://github.com/matrix-org/matrix-react-sdk/pull/1816)
* Improve group joining/leaving feedback
[\#1831](https://github.com/matrix-org/matrix-react-sdk/pull/1831)
Changes in [0.12.0-rc.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.6) (2018-04-09)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.5...v0.12.0-rc.6)
* Fix group join button not appearing
Changes in [0.12.0-rc.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.5) (2018-04-09)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.4...v0.12.0-rc.5)
* Added radio button to set group join policy
* Fix to prevent guests from accessing lab features
* Fix broken forgot password page
* Fix crash when joining a room after peeking
Changes in [0.12.0-rc.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.4) (2018-03-22)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.3...v0.12.0-rc.4)
* Fix broken import preventing people tag
[\#1811](https://github.com/matrix-org/matrix-react-sdk/pull/1811)
Changes in [0.12.0-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.3) (2018-03-20)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.2...v0.12.0-rc.3)
* Fix room tile badge not disappearing when receiving a read receipt
[\#1807](https://github.com/matrix-org/matrix-react-sdk/pull/1807)
Changes in [0.12.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.2) (2018-03-19)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.12.0-rc.1...v0.12.0-rc.2)
* Take TagPanel out of labs
[\#1805](https://github.com/matrix-org/matrix-react-sdk/pull/1805)
Changes in [0.12.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.12.0-rc.1) (2018-03-19)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.4...v0.12.0-rc.1)
* Remove the message on migrating crypto data
[\#1803](https://github.com/matrix-org/matrix-react-sdk/pull/1803)
* Update from Weblate.
[\#1804](https://github.com/matrix-org/matrix-react-sdk/pull/1804)
* Improve room list performance when receiving messages
[\#1801](https://github.com/matrix-org/matrix-react-sdk/pull/1801)
* Add change delay warning in GroupView settings
[\#1802](https://github.com/matrix-org/matrix-react-sdk/pull/1802)
* Only use `dangerouslySetInnerHTML` for HTML messages
[\#1799](https://github.com/matrix-org/matrix-react-sdk/pull/1799)
* Limit group requests to 3 at once
[\#1798](https://github.com/matrix-org/matrix-react-sdk/pull/1798)
* Show GroupMemberList after inviting a group member
[\#1796](https://github.com/matrix-org/matrix-react-sdk/pull/1796)
* Fix syntax fail
[\#1794](https://github.com/matrix-org/matrix-react-sdk/pull/1794)
* Use TintableSvg for TagPanel clear filter button
[\#1793](https://github.com/matrix-org/matrix-react-sdk/pull/1793)
* Fix missing space between "...is a" and user ID
[\#1792](https://github.com/matrix-org/matrix-react-sdk/pull/1792)
* E2E "fudge-button"
[\#1791](https://github.com/matrix-org/matrix-react-sdk/pull/1791)
* Remove spurious console.trace
[\#1790](https://github.com/matrix-org/matrix-react-sdk/pull/1790)
* Don't reset the presence timer on every dispatch
[\#1789](https://github.com/matrix-org/matrix-react-sdk/pull/1789)
* Potentially fix a memory leak in FlairStore
[\#1788](https://github.com/matrix-org/matrix-react-sdk/pull/1788)
* Implement transparent RoomTile for use in some places
[\#1785](https://github.com/matrix-org/matrix-react-sdk/pull/1785)
* Fix varying default group avatar colour for given group
[\#1784](https://github.com/matrix-org/matrix-react-sdk/pull/1784)
* Fix bug where avatar change not reflected in LLP
[\#1783](https://github.com/matrix-org/matrix-react-sdk/pull/1783)
* Workaround for atlassian/react-beautiful-dnd#273
[\#1782](https://github.com/matrix-org/matrix-react-sdk/pull/1782)
* Add setting to disable TagPanel
[\#1781](https://github.com/matrix-org/matrix-react-sdk/pull/1781)
* [DO NOT MERGE] Tests proven to fail
[\#1780](https://github.com/matrix-org/matrix-react-sdk/pull/1780)
* Fix room power level settings
[\#1779](https://github.com/matrix-org/matrix-react-sdk/pull/1779)
* fix shouldHideEvent saying an event is a leave/join when a profile ch…
[\#1769](https://github.com/matrix-org/matrix-react-sdk/pull/1769)
* Add "Did you know:..." microcopy to groups view
[\#1777](https://github.com/matrix-org/matrix-react-sdk/pull/1777)
* Give emptySubListTip a container for correct bg colour
[\#1753](https://github.com/matrix-org/matrix-react-sdk/pull/1753)
* Do proper null-checks on decypted events to fix NPEs
[\#1776](https://github.com/matrix-org/matrix-react-sdk/pull/1776)
* Reorder the RoomListStore lists on Event.decrypted
[\#1775](https://github.com/matrix-org/matrix-react-sdk/pull/1775)
* Fix bug where global "Never send to unverified..." is ignored
[\#1772](https://github.com/matrix-org/matrix-react-sdk/pull/1772)
* Fix bug that prevented tint updates
[\#1767](https://github.com/matrix-org/matrix-react-sdk/pull/1767)
* Fix group member spinner being out of flex order
[\#1765](https://github.com/matrix-org/matrix-react-sdk/pull/1765)
* Allow widget iframes to request camera and microphone permissions.
[\#1766](https://github.com/matrix-org/matrix-react-sdk/pull/1766)
* Change icon from "R" to "X"
[\#1764](https://github.com/matrix-org/matrix-react-sdk/pull/1764)
* Regenerate room lists on Room event
[\#1762](https://github.com/matrix-org/matrix-react-sdk/pull/1762)
* Fix DMs being marked as with the current user ("me")
[\#1761](https://github.com/matrix-org/matrix-react-sdk/pull/1761)
* Make RoomListStore aware of Room.timeline events
[\#1756](https://github.com/matrix-org/matrix-react-sdk/pull/1756)
* improve origin check of ScalarMessaging postmessage API.
[\#1760](https://github.com/matrix-org/matrix-react-sdk/pull/1760)
* Implement global filter to deselect all tags
[\#1759](https://github.com/matrix-org/matrix-react-sdk/pull/1759)
* Don't show empty custom tags when filtering tags
[\#1758](https://github.com/matrix-org/matrix-react-sdk/pull/1758)
* Do not assume that tags have been removed
[\#1757](https://github.com/matrix-org/matrix-react-sdk/pull/1757)
* Change CSS class for message panel spinner
[\#1747](https://github.com/matrix-org/matrix-react-sdk/pull/1747)
* Remove RoomListStore listener
[\#1752](https://github.com/matrix-org/matrix-react-sdk/pull/1752)
* Implement GroupTile avatar dragging to TagPanel
[\#1751](https://github.com/matrix-org/matrix-react-sdk/pull/1751)
* Fix custom tags not being ordered manually
[\#1750](https://github.com/matrix-org/matrix-react-sdk/pull/1750)
* Store component state for editors
[\#1746](https://github.com/matrix-org/matrix-react-sdk/pull/1746)
* Give the login page its spinner back
[\#1745](https://github.com/matrix-org/matrix-react-sdk/pull/1745)
* Add context menu to TagTile
[\#1743](https://github.com/matrix-org/matrix-react-sdk/pull/1743)
* If a tag is unrecognised, assume manual ordering
[\#1748](https://github.com/matrix-org/matrix-react-sdk/pull/1748)
* Move RoomList state to RoomListStore
[\#1719](https://github.com/matrix-org/matrix-react-sdk/pull/1719)
* Move groups button to TagPanel
[\#1744](https://github.com/matrix-org/matrix-react-sdk/pull/1744)
* Add seconds to timestamp on hover
[\#1738](https://github.com/matrix-org/matrix-react-sdk/pull/1738)
* Do not truncate autocompleted users in composer
[\#1739](https://github.com/matrix-org/matrix-react-sdk/pull/1739)
* RoomView: guard against unmounting during peeking
[\#1737](https://github.com/matrix-org/matrix-react-sdk/pull/1737)
* Fix HS/IS URL reset when switching to Registration
[\#1736](https://github.com/matrix-org/matrix-react-sdk/pull/1736)
* Fix the reject/accept call buttons in canary (mk2)
[\#1734](https://github.com/matrix-org/matrix-react-sdk/pull/1734)
* Make ratelimitedfunc time from the function's end
[\#1731](https://github.com/matrix-org/matrix-react-sdk/pull/1731)
* Give dialogs a matrixClient context
[\#1735](https://github.com/matrix-org/matrix-react-sdk/pull/1735)
* Fix key bindings in address picker dialog
[\#1732](https://github.com/matrix-org/matrix-react-sdk/pull/1732)
* Try upgrading eslint-plugin-react
[\#1712](https://github.com/matrix-org/matrix-react-sdk/pull/1712)
* Fix display name change text
[\#1730](https://github.com/matrix-org/matrix-react-sdk/pull/1730)
* Persist contentState when sending SlashCommand via MessageComposerInput
[\#1721](https://github.com/matrix-org/matrix-react-sdk/pull/1721)
* This is actually MFileBody not MImageBody, change classname
[\#1726](https://github.com/matrix-org/matrix-react-sdk/pull/1726)
* Use invite_3pid prop of createRoom instead of manual invite after create
[\#1717](https://github.com/matrix-org/matrix-react-sdk/pull/1717)
* guard against m.room.aliases events with no keys (redaction?)
[\#1729](https://github.com/matrix-org/matrix-react-sdk/pull/1729)
* Fix not showing Invited section if all invites are 3PID
[\#1718](https://github.com/matrix-org/matrix-react-sdk/pull/1718)
* Fix Rich Replies on files
[\#1720](https://github.com/matrix-org/matrix-react-sdk/pull/1720)
* Update from Weblate.
[\#1728](https://github.com/matrix-org/matrix-react-sdk/pull/1728)
* Null guard against falsey (non-null) props.node, to make react happy
[\#1724](https://github.com/matrix-org/matrix-react-sdk/pull/1724)
* Use correct condition for getting account data after first sync
[\#1722](https://github.com/matrix-org/matrix-react-sdk/pull/1722)
* Fix order calculation logic when reordering a room
[\#1725](https://github.com/matrix-org/matrix-react-sdk/pull/1725)
* Linear Rich Quoting
[\#1715](https://github.com/matrix-org/matrix-react-sdk/pull/1715)
* Fix CreateGroupDialog issues
[\#1714](https://github.com/matrix-org/matrix-react-sdk/pull/1714)
* Show a warning if the user attempts to leave a room that is invite only
[\#1713](https://github.com/matrix-org/matrix-react-sdk/pull/1713)
* Swap RoomList to react-beautiful-dnd
[\#1711](https://github.com/matrix-org/matrix-react-sdk/pull/1711)
* don't pass back {} when we have no `org.matrix.room.color_scheme`
[\#1710](https://github.com/matrix-org/matrix-react-sdk/pull/1710)
* Don't paginate whilst decrypting events
[\#1700](https://github.com/matrix-org/matrix-react-sdk/pull/1700)
* Fall back for missing i18n plurals
[\#1699](https://github.com/matrix-org/matrix-react-sdk/pull/1699)
* Fix group store redundant requests
[\#1709](https://github.com/matrix-org/matrix-react-sdk/pull/1709)
* Ignore remote echos caused by this client
[\#1708](https://github.com/matrix-org/matrix-react-sdk/pull/1708)
* Replace TagPanel react-dnd with react-beautiful-dnd
[\#1705](https://github.com/matrix-org/matrix-react-sdk/pull/1705)
* Only set selected tags state when updating rooms
[\#1704](https://github.com/matrix-org/matrix-react-sdk/pull/1704)
* Add formatFullDateNoTime to DateUtils and stop passing 12/24h to DateSep
[\#1702](https://github.com/matrix-org/matrix-react-sdk/pull/1702)
* Fix autofocus on QuestionDialog
[\#1698](https://github.com/matrix-org/matrix-react-sdk/pull/1698)
* Iterative fixes on Rich Quoting
[\#1697](https://github.com/matrix-org/matrix-react-sdk/pull/1697)
* Fix missing negation
[\#1696](https://github.com/matrix-org/matrix-react-sdk/pull/1696)
* Add Analytics Info and add Piwik to SdkConfig.DEFAULTS
[\#1625](https://github.com/matrix-org/matrix-react-sdk/pull/1625)
* Attempt to re-register for a scalar token if ours is invalid
[\#1668](https://github.com/matrix-org/matrix-react-sdk/pull/1668)
* Normalise dialogs
[\#1674](https://github.com/matrix-org/matrix-react-sdk/pull/1674)
* Add 'send without verifying' to status bar
[\#1695](https://github.com/matrix-org/matrix-react-sdk/pull/1695)
* Implement Rich Quoting/Replies
[\#1660](https://github.com/matrix-org/matrix-react-sdk/pull/1660)
* Revert "MD-escape URLs/alises/user IDs prior to parsing markdown"
[\#1694](https://github.com/matrix-org/matrix-react-sdk/pull/1694)
* Cache isConfCallRoom
[\#1693](https://github.com/matrix-org/matrix-react-sdk/pull/1693)
* Improve performance of tag panel selection (when tags are selected)
[\#1687](https://github.com/matrix-org/matrix-react-sdk/pull/1687)
* Hide status bar on visible->hidden transition
[\#1680](https://github.com/matrix-org/matrix-react-sdk/pull/1680)
* [revived] Singularise unsent message prompt, if applicable
[\#1692](https://github.com/matrix-org/matrix-react-sdk/pull/1692)
* small refactor && warn on self-demotion
[\#1683](https://github.com/matrix-org/matrix-react-sdk/pull/1683)
* Remove use of deprecated React.PropTypes
[\#1677](https://github.com/matrix-org/matrix-react-sdk/pull/1677)
* only save RelatedGroupSettings if it was modified. Otherwise perms issue
[\#1691](https://github.com/matrix-org/matrix-react-sdk/pull/1691)
* Fix a couple more issues with granular settings
[\#1675](https://github.com/matrix-org/matrix-react-sdk/pull/1675)
* Allow argument to op slashcommand to be negative as PLs can be -ve
[\#1673](https://github.com/matrix-org/matrix-react-sdk/pull/1673)
* Update from Weblate.
[\#1645](https://github.com/matrix-org/matrix-react-sdk/pull/1645)
* make RoomDetailRow reusable for the Room Directory
[\#1624](https://github.com/matrix-org/matrix-react-sdk/pull/1624)
* Prefetch group data for all joined groups when RoomList mounts
[\#1686](https://github.com/matrix-org/matrix-react-sdk/pull/1686)
* Remove unused selectedRoom prop
[\#1690](https://github.com/matrix-org/matrix-react-sdk/pull/1690)
* Fix shift and shift-ctrl click in TagPanel
[\#1684](https://github.com/matrix-org/matrix-react-sdk/pull/1684)
* skip direct chats which either you or the target have left
[\#1344](https://github.com/matrix-org/matrix-react-sdk/pull/1344)
* Make scroll on paste in RTE compatible with https://github.com/vector-im
/riot-web/pull/5900
[\#1682](https://github.com/matrix-org/matrix-react-sdk/pull/1682)
* Remove extra full stop
[\#1685](https://github.com/matrix-org/matrix-react-sdk/pull/1685)
* Dedupe requests to fetch group profile data
[\#1666](https://github.com/matrix-org/matrix-react-sdk/pull/1666)
* Get Group profile from TagTile instead of TagPanel
[\#1667](https://github.com/matrix-org/matrix-react-sdk/pull/1667)
* Fix leaking of GroupStore listeners in RoomList
[\#1664](https://github.com/matrix-org/matrix-react-sdk/pull/1664)
* Add option to also output untranslated string
[\#1658](https://github.com/matrix-org/matrix-react-sdk/pull/1658)
* Give the current theme to widgets and the integration manager
[\#1669](https://github.com/matrix-org/matrix-react-sdk/pull/1669)
* Fixes #1953 Allow multiple file uploads using drag & drop for RoomView
[\#1671](https://github.com/matrix-org/matrix-react-sdk/pull/1671)
* Fix issue with preview of phone number on register and waiting for sms code
confirmation code
[\#1670](https://github.com/matrix-org/matrix-react-sdk/pull/1670)
* Attempt to improve TagPanel performance
[\#1647](https://github.com/matrix-org/matrix-react-sdk/pull/1647)
* Fix one variant of a scroll jump that occurs when decrypting an m.text
[\#1656](https://github.com/matrix-org/matrix-react-sdk/pull/1656)
* Avoid NPEs by using ref method for collecting loggedInView in MatrixChat
[\#1665](https://github.com/matrix-org/matrix-react-sdk/pull/1665)
* DnD Ordered TagPanel
[\#1653](https://github.com/matrix-org/matrix-react-sdk/pull/1653)
* Update widget title on edit.
[\#1663](https://github.com/matrix-org/matrix-react-sdk/pull/1663)
* Set widget title
[\#1661](https://github.com/matrix-org/matrix-react-sdk/pull/1661)
* Display custom widget content titles
[\#1650](https://github.com/matrix-org/matrix-react-sdk/pull/1650)
* Add maximize / minimize apps drawer icons.
[\#1649](https://github.com/matrix-org/matrix-react-sdk/pull/1649)
* Warn when migrating e2e data to indexeddb
[\#1654](https://github.com/matrix-org/matrix-react-sdk/pull/1654)
* Don't Auto-show UnknownDeviceDialog
[\#1600](https://github.com/matrix-org/matrix-react-sdk/pull/1600)
* Remove logging.
[\#1655](https://github.com/matrix-org/matrix-react-sdk/pull/1655)
* Add messaging endpoint for room encryption status.
[\#1648](https://github.com/matrix-org/matrix-react-sdk/pull/1648)
* Add some missing translatable strings
[\#1588](https://github.com/matrix-org/matrix-react-sdk/pull/1588)
* Add widget -> riot postMessage API
[\#1640](https://github.com/matrix-org/matrix-react-sdk/pull/1640)
* Add some null checks
[\#1646](https://github.com/matrix-org/matrix-react-sdk/pull/1646)
* Implement shift-click and ctrl-click semantics for TP
[\#1641](https://github.com/matrix-org/matrix-react-sdk/pull/1641)
* Don't show group when clicking tag panel
[\#1642](https://github.com/matrix-org/matrix-react-sdk/pull/1642)
* Implement TagPanel (or LeftLeftPanel) for group filtering
[\#1639](https://github.com/matrix-org/matrix-react-sdk/pull/1639)
* Implement UI for using bulk device deletion API
[\#1638](https://github.com/matrix-org/matrix-react-sdk/pull/1638)
* Replace (IRC) with flair
[\#1637](https://github.com/matrix-org/matrix-react-sdk/pull/1637)
* Allow guests to view individual groups
[\#1635](https://github.com/matrix-org/matrix-react-sdk/pull/1635)
* Allow guest to see MyGroups, show ILAG when creating a group
[\#1636](https://github.com/matrix-org/matrix-react-sdk/pull/1636)
* Move group publication toggles to UserSettings
[\#1634](https://github.com/matrix-org/matrix-react-sdk/pull/1634)
* Pull the theme through the default process
[\#1617](https://github.com/matrix-org/matrix-react-sdk/pull/1617)
* Rebase ConfirmRedactDialog on QuestionDialog
[\#1630](https://github.com/matrix-org/matrix-react-sdk/pull/1630)
* Fix logging of missing substitution variables
[\#1629](https://github.com/matrix-org/matrix-react-sdk/pull/1629)
* Rename Related Groups to improve readability
[\#1632](https://github.com/matrix-org/matrix-react-sdk/pull/1632)
* Make PresenceLabel more easily translatable
[\#1616](https://github.com/matrix-org/matrix-react-sdk/pull/1616)
* Perform substitution on all parts, not just the last one
[\#1618](https://github.com/matrix-org/matrix-react-sdk/pull/1618)
* Send Access Token in Headers to help prevent it being spit out in errors
[\#1552](https://github.com/matrix-org/matrix-react-sdk/pull/1552)
* Add aria-labels to ActionButtons
[\#1628](https://github.com/matrix-org/matrix-react-sdk/pull/1628)
* MemberPresenceAvatar: fix null references
[\#1620](https://github.com/matrix-org/matrix-react-sdk/pull/1620)
* Disable presence controls if there's no presence
[\#1623](https://github.com/matrix-org/matrix-react-sdk/pull/1623)
* Fix GroupMemberList search for users without displayname
[\#1627](https://github.com/matrix-org/matrix-react-sdk/pull/1627)
* Remove redundant super class EventEmitter for FlairStore
[\#1626](https://github.com/matrix-org/matrix-react-sdk/pull/1626)
* Fix granular URL previews
[\#1622](https://github.com/matrix-org/matrix-react-sdk/pull/1622)
* Flairstore: Fix broken reference
[\#1619](https://github.com/matrix-org/matrix-react-sdk/pull/1619)
* Do something more sensible for sender profile name/aux opacity
[\#1615](https://github.com/matrix-org/matrix-react-sdk/pull/1615)
* Add eslint rule keyword-spacing
[\#1614](https://github.com/matrix-org/matrix-react-sdk/pull/1614)
* Fix various issues surrounding granular settings to date
[\#1613](https://github.com/matrix-org/matrix-react-sdk/pull/1613)
* differentiate between state events and message events
[\#1612](https://github.com/matrix-org/matrix-react-sdk/pull/1612)
* Refactor translations
[\#1608](https://github.com/matrix-org/matrix-react-sdk/pull/1608)
* Make TintableSvg links behave like normal image links
[\#1611](https://github.com/matrix-org/matrix-react-sdk/pull/1611)
* Fix linting errors.
[\#1610](https://github.com/matrix-org/matrix-react-sdk/pull/1610)
* Granular settings
[\#1516](https://github.com/matrix-org/matrix-react-sdk/pull/1516)
* Implement user-controlled presence
[\#1482](https://github.com/matrix-org/matrix-react-sdk/pull/1482)
* Edit widget icon styling
[\#1609](https://github.com/matrix-org/matrix-react-sdk/pull/1609)
* Attempt to improve textual power levels
[\#1607](https://github.com/matrix-org/matrix-react-sdk/pull/1607)
* Determine whether power level is custom once Roles have been determined
[\#1606](https://github.com/matrix-org/matrix-react-sdk/pull/1606)
* Status.im theme
[\#1605](https://github.com/matrix-org/matrix-react-sdk/pull/1605)
* Revert "Lowercase all usernames"
[\#1604](https://github.com/matrix-org/matrix-react-sdk/pull/1604)
Changes in [0.11.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.4) (2018-02-09)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.3...v0.11.4)
* Add isUrlPermitted function to sanity check URLs
Changes in [0.11.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.3) (2017-12-04) 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) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.11.3", "version": "0.12.1",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -56,7 +56,7 @@
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"classnames": "^2.1.2", "classnames": "^2.1.2",
"commonmark": "^0.27.0", "commonmark": "^0.28.1",
"counterpart": "^0.18.0", "counterpart": "^0.18.0",
"draft-js": "^0.11.0-alpha", "draft-js": "^0.11.0-alpha",
"draft-js-export-html": "^0.6.0", "draft-js-export-html": "^0.6.0",
@ -65,20 +65,20 @@
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"filesize": "3.5.6", "filesize": "3.5.6",
"flux": "2.1.1", "flux": "2.1.1",
"focus-trap-react": "^3.0.5",
"fuse.js": "^2.2.0", "fuse.js": "^2.2.0",
"glob": "^5.0.14", "glob": "^5.0.14",
"highlight.js": "^8.9.1", "highlight.js": "^8.9.1",
"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.9.2", "matrix-js-sdk": "0.10.0",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"querystring": "^0.2.0", "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-beautiful-dnd": "^4.0.0",
"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",
@ -129,7 +129,7 @@
"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.2.3",
"walk": "^2.3.9", "walk": "^2.3.9",
"webpack": "^1.12.14" "webpack": "^1.12.14"
} }

View file

@ -14,25 +14,54 @@
limitations under the License. limitations under the License.
*/ */
import { getCurrentLanguage } from './languageHandler'; import { getCurrentLanguage, _t, _td } from './languageHandler';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig, { DEFAULTS } from './SdkConfig';
import Modal from './Modal';
import sdk from './index';
function getRedactedHash() {
return window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/<redacted>");
}
function getRedactedUrl() { function getRedactedUrl() {
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/' + getRedactedHash();
} }
const customVariables = { const customVariables = {
'App Platform': 1, 'App Platform': {
'App Version': 2, id: 1,
'User Type': 3, expl: _td('The platform you\'re on'),
'Chosen Language': 4, },
'Instance': 5, 'App Version': {
'RTE: Uses Richtext Mode': 6, id: 2,
'Homeserver URL': 7, expl: _td('The version of Riot.im'),
'Identity Server URL': 8, },
'User Type': {
id: 3,
expl: _td('Whether or not you\'re logged in (we don\'t record your user name)'),
},
'Chosen Language': {
id: 4,
expl: _td('Your language of choice'),
},
'Instance': {
id: 5,
expl: _td('Which officially provided instance you are using, if any'),
},
'RTE: Uses Richtext Mode': {
id: 6,
expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'),
},
'Homeserver URL': {
id: 7,
expl: _td('Your homeserver\'s URL'),
},
'Identity Server URL': {
id: 8,
expl: _td('Your identity server\'s URL'),
},
}; };
function whitelistRedact(whitelist, str) { function whitelistRedact(whitelist, str) {
@ -40,9 +69,6 @@ function whitelistRedact(whitelist, str) {
return '<redacted>'; return '<redacted>';
} }
const whitelistedHSUrls = ["https://matrix.org"];
const whitelistedISUrls = ["https://vector.im"];
class Analytics { class Analytics {
constructor() { constructor() {
this._paq = null; this._paq = null;
@ -66,6 +92,10 @@ class Analytics {
*/ */
disable() { disable() {
this.trackEvent('Analytics', 'opt-out'); this.trackEvent('Analytics', 'opt-out');
// disableHeartBeatTimer is undocumented but exists in the piwik code
// the _paq.push method will result in an error being printed in the console
// if an unknown method signature is passed
this._paq.push(['disableHeartBeatTimer']);
this.disabled = true; this.disabled = true;
} }
@ -117,7 +147,10 @@ class Analytics {
return true; return true;
} }
trackPageChange() { trackPageChange(generationTimeMs) {
if (typeof generationTimeMs !== 'number') {
throw new Error('Analytics.trackPageChange: expected generationTimeMs to be a number');
}
if (this.disabled) return; if (this.disabled) return;
if (this.firstPage) { if (this.firstPage) {
// De-duplicate first page // De-duplicate first page
@ -126,6 +159,7 @@ class Analytics {
return; return;
} }
this._paq.push(['setCustomUrl', getRedactedUrl()]); this._paq.push(['setCustomUrl', getRedactedUrl()]);
this._paq.push(['setGenerationTimeMs', generationTimeMs]);
this._paq.push(['trackPageView']); this._paq.push(['trackPageView']);
} }
@ -140,11 +174,16 @@ class Analytics {
} }
_setVisitVariable(key, value) { _setVisitVariable(key, value) {
this._paq.push(['setCustomVariable', customVariables[key], key, value, 'visit']); this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']);
} }
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
if (this.disabled) return; if (this.disabled) return;
const config = SdkConfig.get();
const whitelistedHSUrls = config.piwik.whitelistedHSUrls || DEFAULTS.piwik.whitelistedHSUrls;
const whitelistedISUrls = config.piwik.whitelistedISUrls || DEFAULTS.piwik.whitelistedISUrls;
this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl)); this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl));
@ -154,6 +193,44 @@ class Analytics {
if (this.disabled) return; if (this.disabled) return;
this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off'); this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off');
} }
showDetailsModal() {
const Tracker = window.Piwik.getAsyncTracker();
const rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean);
const resolution = `${window.screen.width}x${window.screen.height}`;
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, {
title: _t('Analytics'),
description: <div>
<div>
{ _t('The information being sent to us to help make Riot.im better includes:') }
</div>
<table>
{ rows.map((row) => <tr key={row[0]}>
<td>{ _t(customVariables[row[0]].expl) }</td>
<td><code>{ row[1] }</code></td>
</tr>) }
</table>
<br />
<div>
{ _t('We also record each page you use in the app (currently <CurrentPageHash>), your User Agent'
+ ' (<CurrentUserAgent>) and your device resolution (<CurrentDeviceResolution>).',
{},
{
CurrentPageHash: <code>{ getRedactedHash() }</code>,
CurrentUserAgent: <code>{ navigator.userAgent }</code>,
CurrentDeviceResolution: <code>{ resolution }</code>,
},
) }
{ _t('Where this page includes identifiable information, such as a room, '
+ 'user or group ID, that data is removed before being sent to the server.') }
</div>
</div>,
});
}
} }
if (!global.mxAnalytics) { if (!global.mxAnalytics) {

View file

@ -275,6 +275,13 @@ class ContentMessages {
this.nextId = 0; this.nextId = 0;
} }
sendStickerContentToRoom(url, roomId, info, text, matrixClient) {
return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
throw e;
});
}
sendContentToRoom(file, roomId, matrixClient) { sendContentToRoom(file, roomId, matrixClient) {
const content = { const content = {
body: file.name || 'Attachment', body: file.name || 'Attachment',

View file

@ -15,7 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
function getDaysArray() { function getDaysArray() {
@ -51,26 +50,29 @@ function pad(n) {
return (n < 10 ? '0' : '') + n; return (n < 10 ? '0' : '') + n;
} }
function twelveHourTime(date) { function twelveHourTime(date, showSeconds=false) {
let hours = date.getHours() % 12; let hours = date.getHours() % 12;
const minutes = pad(date.getMinutes()); const minutes = pad(date.getMinutes());
const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM');
hours = hours ? hours : 12; // convert 0 -> 12 hours = hours ? hours : 12; // convert 0 -> 12
if (showSeconds) {
const seconds = pad(date.getSeconds());
return `${hours}:${minutes}:${seconds}${ampm}`;
}
return `${hours}:${minutes}${ampm}`; return `${hours}:${minutes}${ampm}`;
} }
module.exports = { export function formatDate(date, showTwelveHour=false) {
formatDate: function(date, showTwelveHour=false) {
const now = new Date(); const now = new Date();
const days = getDaysArray(); const days = getDaysArray();
const months = getMonthsArray(); const months = getMonthsArray();
if (date.toDateString() === now.toDateString()) { if (date.toDateString() === now.toDateString()) {
return this.formatTime(date, showTwelveHour); return formatTime(date, showTwelveHour);
} else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
// TODO: use standard date localize function provided in counterpart // TODO: use standard date localize function provided in counterpart
return _t('%(weekDayName)s %(time)s', { return _t('%(weekDayName)s %(time)s', {
weekDayName: days[date.getDay()], weekDayName: days[date.getDay()],
time: this.formatTime(date, showTwelveHour), time: formatTime(date, showTwelveHour),
}); });
} else if (now.getFullYear() === date.getFullYear()) { } else if (now.getFullYear() === date.getFullYear()) {
// TODO: use standard date localize function provided in counterpart // TODO: use standard date localize function provided in counterpart
@ -78,13 +80,24 @@ module.exports = {
weekDayName: days[date.getDay()], weekDayName: days[date.getDay()],
monthName: months[date.getMonth()], monthName: months[date.getMonth()],
day: date.getDate(), day: date.getDate(),
time: this.formatTime(date, showTwelveHour), time: formatTime(date, showTwelveHour),
}); });
} }
return this.formatFullDate(date, showTwelveHour); return formatFullDate(date, showTwelveHour);
}, }
formatFullDate: function(date, showTwelveHour=false) { export function formatFullDateNoTime(date) {
const days = getDaysArray();
const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', {
weekDayName: days[date.getDay()],
monthName: months[date.getMonth()],
day: date.getDate(),
fullYear: date.getFullYear(),
});
}
export function formatFullDate(date, showTwelveHour=false) {
const days = getDaysArray(); const days = getDaysArray();
const months = getMonthsArray(); const months = getMonthsArray();
return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', {
@ -92,14 +105,34 @@ module.exports = {
monthName: months[date.getMonth()], monthName: months[date.getMonth()],
day: date.getDate(), day: date.getDate(),
fullYear: date.getFullYear(), fullYear: date.getFullYear(),
time: this.formatTime(date, showTwelveHour), time: formatFullTime(date, showTwelveHour),
}); });
}, }
formatTime: function(date, showTwelveHour=false) { export function formatFullTime(date, showTwelveHour=false) {
if (showTwelveHour) {
return twelveHourTime(date, true);
}
return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds());
}
export function formatTime(date, showTwelveHour=false) {
if (showTwelveHour) { if (showTwelveHour) {
return twelveHourTime(date); return twelveHourTime(date);
} }
return pad(date.getHours()) + ':' + pad(date.getMinutes()); return pad(date.getHours()) + ':' + pad(date.getMinutes());
}, }
};
const MILLIS_IN_DAY = 86400000;
export function wantsDateSeparator(prevEventDate, nextEventDate) {
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart
if (Math.abs(prevEventDate.getTime() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
return true;
}
// Compare weekdays
return prevEventDate.getDay() !== nextEventDate.getDay();
}

View file

@ -0,0 +1,201 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the 'License');
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an 'AS IS' BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import URL from 'url';
import dis from './dispatcher';
import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
const WIDGET_API_VERSION = '0.0.1'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
'0.0.1',
];
const INBOUND_API_NAME = 'fromWidget';
// Listen for and handle incomming requests using the 'fromWidget' postMessage
// API and initiate responses
export default class FromWidgetPostMessageApi {
constructor() {
this.widgetMessagingEndpoints = [];
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.onPostMessage = this.onPostMessage.bind(this);
}
start() {
window.addEventListener('message', this.onPostMessage);
}
stop() {
window.removeEventListener('message', this.onPostMessage);
}
/**
* Register a widget endpoint for trusted postMessage communication
* @param {string} widgetId Unique widget identifier
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
*/
addEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl);
return;
}
const origin = u.protocol + '//' + u.host;
const endpoint = new WidgetMessagingEndpoint(widgetId, origin);
if (this.widgetMessagingEndpoints.some(function(ep) {
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
})) {
// Message endpoint already registered
console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
return;
} else {
console.warn(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
this.widgetMessagingEndpoints.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
*/
removeEndpoint(widgetId, endpointUrl) {
const u = URL.parse(endpointUrl);
if (!u || !u.protocol || !u.host) {
console.warn('Remove widget messaging endpoint - Invalid origin');
return;
}
const origin = u.protocol + '//' + u.host;
if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) {
const length = this.widgetMessagingEndpoints.length;
this.widgetMessagingEndpoints = this.widgetMessagingEndpoints.
filter(function(endpoint) {
return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
});
return (length > this.widgetMessagingEndpoints.length);
}
return false;
}
/**
* Handle widget postMessage events
* Messages are only handled where a valid, registered messaging endpoints
* @param {Event} event Event to handle
* @return {undefined}
*/
onPostMessage(event) {
if (!event.origin) { // Handle chrome
event.origin = event.originalEvent.origin;
}
// Event origin is empty string if undefined
if (
event.origin.length === 0 ||
!this.trustedEndpoint(event.origin) ||
event.data.api !== INBOUND_API_NAME ||
!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') {
console.warn('Widget reported content loaded for', widgetId);
dis.dispatch({
action: 'widget_content_loaded',
widgetId: widgetId,
});
this.sendResponse(event, {success: true});
} else if (action === 'supported_api_versions') {
this.sendResponse(event, {
api: INBOUND_API_NAME,
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
});
} else if (action === 'api_version') {
this.sendResponse(event, {
api: INBOUND_API_NAME,
version: WIDGET_API_VERSION,
});
} else if (action === 'm.sticker') {
// console.warn('Got sticker message from widget', widgetId);
dis.dispatch({action: 'm.sticker', data: event.data.widgetData, widgetId: event.data.widgetId});
} else if (action === 'integration_manager_open') {
// Close the stickerpicker
dis.dispatch({action: 'stickerpicker_close'});
// Open the integration manager
const data = event.data.widgetData;
const integType = (data && data.integType) ? data.integType : null;
const integId = (data && data.integId) ? data.integId : null;
IntegrationManager.open(integType, integId);
} else {
console.warn('Widget postMessage event unhandled');
this.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
*/
trustedEndpoint(origin) {
if (!origin) {
return false;
}
return this.widgetMessagingEndpoints.some((endpoint) => {
// TODO / FIXME -- Should this also check the widgetId?
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
*/
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)
*/
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);
}
}

View file

@ -22,6 +22,7 @@ import MatrixClientPeg from './MatrixClientPeg';
import GroupStoreCache from './stores/GroupStoreCache'; import GroupStoreCache from './stores/GroupStoreCache';
export function showGroupInviteDialog(groupId) { export function showGroupInviteDialog(groupId) {
return new Promise((resolve, reject) => {
const description = <div> const description = <div>
<div>{ _t("Who would you like to add to this community?") }</div> <div>{ _t("Who would you like to add to this community?") }</div>
<div className="warning"> <div className="warning">
@ -42,9 +43,10 @@ export function showGroupInviteDialog(groupId) {
onFinished: (success, addrs) => { onFinished: (success, addrs) => {
if (!success) return; if (!success) return;
_onGroupInviteFinished(groupId, addrs); _onGroupInviteFinished(groupId, addrs).then(resolve, reject);
}, },
}); });
});
} }
export function showGroupAddRoomDialog(groupId) { export function showGroupAddRoomDialog(groupId) {
@ -87,7 +89,7 @@ function _onGroupInviteFinished(groupId, addrs) {
const addrTexts = addrs.map((addr) => addr.address); const addrTexts = addrs.map((addr) => addr.address);
multiInviter.invite(addrTexts).then((completionStates) => { return multiInviter.invite(addrTexts).then((completionStates) => {
// Show user any errors // Show user any errors
const errorList = []; const errorList = [];
for (const addr of Object.keys(completionStates)) { for (const addr of Object.keys(completionStates)) {

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -25,6 +25,7 @@ import escape from 'lodash/escape';
import emojione from 'emojione'; import emojione from 'emojione';
import classNames from 'classnames'; import classNames from 'classnames';
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import url from 'url';
emojione.imagePathSVG = 'emojione/svg/'; emojione.imagePathSVG = 'emojione/svg/';
// Store PNG path for displaying many flags at once (for increased performance over SVG) // Store PNG path for displaying many flags at once (for increased performance over SVG)
@ -44,6 +45,8 @@ const SYMBOL_PATTERN = /([\u2100-\u2bff])/;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
/* /*
* Return true if the given string contains emoji * Return true if the given string contains emoji
* Uses a much, much simpler regex than emojione's so will give false * Uses a much, much simpler regex than emojione's so will give false
@ -152,6 +155,25 @@ export function sanitizedHtmlNode(insaneHtml) {
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />; return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
} }
/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
* Note that the HTML sanitiser library has its own internal logic for
* doing this, to which we pass the same list of schemes. This is used in
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
export function isUrlPermitted(inputUrl) {
try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
// URL parser protocol includes the trailing colon
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1));
} catch (e) {
return false;
}
}
const sanitizeHtmlParams = { const sanitizeHtmlParams = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
@ -172,7 +194,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', 'magnet'], allowedSchemes: PERMITTED_URL_SCHEMES,
allowProtocolRelative: false, allowProtocolRelative: false,
@ -388,8 +410,7 @@ class TextHighlighter extends BaseHighlighter {
* opts.disableBigEmoji: optional argument to disable the big emoji class. * opts.disableBigEmoji: optional argument to disable the big emoji class.
*/ */
export function bodyToHtml(content, highlights, opts={}) { export function bodyToHtml(content, highlights, opts={}) {
const isHtml = (content.format === "org.matrix.custom.html"); let isHtml = (content.format === "org.matrix.custom.html");
const body = isHtml ? content.formatted_body : escape(content.body);
let bodyHasEmoji = false; let bodyHasEmoji = false;
@ -409,9 +430,27 @@ export function bodyToHtml(content, highlights, opts={}) {
return highlighter.applyHighlights(safeText, safeHighlights).join(''); return highlighter.applyHighlights(safeText, safeHighlights).join('');
}; };
} }
safeBody = sanitizeHtml(body, sanitizeHtmlParams);
bodyHasEmoji = containsEmoji(body); bodyHasEmoji = containsEmoji(isHtml ? content.formatted_body : content.body);
if (bodyHasEmoji) safeBody = unicodeToImage(safeBody);
// Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtml) {
safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams);
} else {
// ... or if there are emoji, which we insert as HTML alongside the
// escaped plaintext body.
if (bodyHasEmoji) {
isHtml = true;
safeBody = sanitizeHtml(escape(content.body), sanitizeHtmlParams);
}
}
// An HTML message with emoji
// or a plaintext message with emoji that was escaped and sanitized into
// HTML.
if (bodyHasEmoji) {
safeBody = unicodeToImage(safeBody);
}
} finally { } finally {
delete sanitizeHtmlParams.textFilter; delete sanitizeHtmlParams.textFilter;
} }
@ -429,7 +468,10 @@ export function bodyToHtml(content, highlights, opts={}) {
'mx_EventTile_bigEmoji': emojiBody, 'mx_EventTile_bigEmoji': emojiBody,
'markdown-body': isHtml, 'markdown-body': isHtml,
}); });
return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" />;
return isHtml ?
<span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" /> :
<span className={className} dir="auto">{ content.body }</span>;
} }
export function emojifyText(text) { export function emojifyText(text) {

73
src/IntegrationManager.js Normal file
View file

@ -0,0 +1,73 @@
/*
Copyright 2017 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Modal from './Modal';
import sdk from './index';
import SdkConfig from './SdkConfig';
import ScalarMessaging from './ScalarMessaging';
import ScalarAuthClient from './ScalarAuthClient';
import RoomViewStore from './stores/RoomViewStore';
if (!global.mxIntegrationManager) {
global.mxIntegrationManager = {};
}
export default class IntegrationManager {
static _init() {
if (!global.mxIntegrationManager.client || !global.mxIntegrationManager.connected) {
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
ScalarMessaging.startListening();
global.mxIntegrationManager.client = new ScalarAuthClient();
return global.mxIntegrationManager.client.connect().then(() => {
global.mxIntegrationManager.connected = true;
}).catch((e) => {
console.error("Failed to connect to integrations server", e);
global.mxIntegrationManager.error = e;
});
} else {
console.error('Invalid integration manager config', SdkConfig.get());
}
}
}
/**
* Launch the integrations manager on the stickers integration page
* @param {string} integName integration / widget type
* @param {string} integId integration / widget ID
* @param {function} onFinished Callback to invoke on integration manager close
*/
static async open(integName, integId, onFinished) {
await IntegrationManager._init();
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
if (global.mxIntegrationManager.error ||
!(global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials())) {
console.error("Scalar error", global.mxIntegrationManager);
return;
}
const integType = 'type_' + integName;
const src = (global.mxIntegrationManager.client && global.mxIntegrationManager.client.hasCredentials()) ?
global.mxIntegrationManager.client.getScalarInterfaceUrlForRoom(
{roomId: RoomViewStore.getRoomId()},
integType,
integId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
onFinished: onFinished,
}, "mx_IntegrationsManager");
}
}

View file

@ -68,3 +68,12 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) {
return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey;
} }
} }
export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
if (isMac) {
return ev.metaKey && !ev.altKey && !ev.ctrlKey;
} else {
return ev.ctrlKey && !ev.altKey && !ev.metaKey;
}
}

View file

@ -362,7 +362,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); dis.dispatch({action: 'on_logged_in', teamToken: teamToken});
}); });
startMatrixClient(); await startMatrixClient();
return MatrixClientPeg.get(); return MatrixClientPeg.get();
} }
@ -423,7 +423,7 @@ export function logout() {
* Starts the matrix client and all other react-sdk services that * Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in. * listen for events while a session is logged in.
*/ */
function startMatrixClient() { async function startMatrixClient() {
console.log(`Lifecycle: Starting MatrixClient`); console.log(`Lifecycle: Starting MatrixClient`);
// dispatch this before starting the matrix client: it's used // dispatch this before starting the matrix client: it's used
@ -437,7 +437,7 @@ function startMatrixClient() {
Presence.start(); Presence.start();
DMRoomMap.makeShared().start(); DMRoomMap.makeShared().start();
MatrixClientPeg.start(); await MatrixClientPeg.start();
// dispatch that we finished starting up to wire up any other bits // dispatch that we finished starting up to wire up any other bits
// of the matrix client that cannot be set prior to starting up. // of the matrix client that cannot be set prior to starting up.

View file

@ -55,25 +55,6 @@ function is_multi_line(node) {
return par.firstChild != par.lastChild; return par.firstChild != par.lastChild;
} }
import linkifyMatrix from './linkify-matrix';
import * as linkify from 'linkifyjs';
linkifyMatrix(linkify);
// Thieved from draft-js-export-markdown
function escapeMarkdown(s) {
return s.replace(/[*_`]/g, '\\$&');
}
// Replace URLs, room aliases and user IDs with md-escaped URLs
function linkifyMarkdown(s) {
const links = linkify.find(s);
links.forEach((l) => {
// This may replace several instances of `l.value` at once, but that's OK
s = s.replace(l.value, escapeMarkdown(l.value));
});
return s;
}
/** /**
* Class that wraps commonmark, adding the ability to see whether * Class that wraps commonmark, adding the ability to see whether
* a given message actually uses any markdown syntax or whether * a given message actually uses any markdown syntax or whether
@ -81,7 +62,7 @@ function linkifyMarkdown(s) {
*/ */
export default class Markdown { export default class Markdown {
constructor(input) { constructor(input) {
this.input = linkifyMarkdown(input); this.input = input;
const parser = new commonmark.Parser(); const parser = new commonmark.Parser();
this.parsed = parser.parse(this.input); this.parsed = parser.parse(this.input);

View file

@ -174,4 +174,4 @@ class MatrixClientPeg {
if (!global.mxMatrixClientPeg) { if (!global.mxMatrixClientPeg) {
global.mxMatrixClientPeg = new MatrixClientPeg(); global.mxMatrixClientPeg = new MatrixClientPeg();
} }
module.exports = global.mxMatrixClientPeg; export default global.mxMatrixClientPeg;

View file

@ -19,8 +19,10 @@ limitations under the License.
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
import PropTypes from 'prop-types';
import Analytics from './Analytics'; import Analytics from './Analytics';
import sdk from './index'; import sdk from './index';
import dis from './dispatcher';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
@ -33,7 +35,7 @@ const AsyncWrapper = React.createClass({
/** A function which takes a 'callback' argument which it will call /** A function which takes a 'callback' argument which it will call
* with the real component once it loads. * with the real component once it loads.
*/ */
loader: React.PropTypes.func.isRequired, loader: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -187,10 +189,22 @@ class ModalManager {
_reRender() { _reRender() {
if (this._modals.length == 0) { if (this._modals.length == 0) {
// If there is no modal to render, make all of Riot available
// to screen reader users again
dis.dispatch({
action: 'aria_unhide_main_app',
});
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
return; return;
} }
// Hide the content outside the modal to screen reader users
// so they won't be able to navigate into it and act on it using
// screen reader specific features
dis.dispatch({
action: 'aria_hide_main_app',
});
const modal = this._modals[0]; const modal = this._modals[0];
const dialog = ( const dialog = (
<div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}> <div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}>

View file

@ -135,6 +135,10 @@ const Notifier = {
const plaf = PlatformPeg.get(); const plaf = PlatformPeg.get();
if (!plaf) return; if (!plaf) return;
// Dev note: We don't set the "notificationsEnabled" setting to true here because it is a
// calculated value. It is determined based upon whether or not the master rule is enabled
// and other flags. Setting it here would cause a circular reference.
Analytics.trackEvent('Notifier', 'Set Enabled', enable); Analytics.trackEvent('Notifier', 'Set Enabled', enable);
// make sure that we persist the current setting audio_enabled setting // make sure that we persist the current setting audio_enabled setting
@ -168,7 +172,7 @@ const Notifier = {
}); });
// clear the notifications_hidden flag, so that if notifications are // clear the notifications_hidden flag, so that if notifications are
// 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(true);
} else { } else {
dis.dispatch({ dis.dispatch({
action: "notifier_enabled", action: "notifier_enabled",
@ -252,6 +256,10 @@ const Notifier = {
}, },
onEventDecrypted: function(ev) { onEventDecrypted: function(ev) {
// 'decrypted' means the decryption process has finished: it may have failed,
// in which case it might decrypt soon if the keys arrive
if (ev.isDecryptionFailure()) return;
const idx = this.pendingEncryptedEventIds.indexOf(ev.getId()); const idx = this.pendingEncryptedEventIds.indexOf(ev.getId());
if (idx === -1) return; if (idx === -1) return;

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -31,7 +32,7 @@ class Presence {
this.running = true; this.running = true;
if (undefined === this.state) { if (undefined === this.state) {
this._resetTimer(); this._resetTimer();
this.dispatcherRef = dis.register(this._onUserActivity.bind(this)); this.dispatcherRef = dis.register(this._onAction.bind(this));
} }
} }
@ -95,9 +96,11 @@ class Presence {
this.setState("unavailable"); this.setState("unavailable");
} }
_onUserActivity() { _onAction(payload) {
if (payload.action === "user_activity") {
this._resetTimer(); this._resetTimer();
} }
}
/** /**
* Callback called when the user made an action on the page * Callback called when the user made an action on the page

View file

@ -85,9 +85,7 @@ function _onStartChatFinished(shouldInvite, addrs) {
if (rooms.length > 0) { if (rooms.length > 0) {
// A Direct Message room already exists for this user, so select a // 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 // room from a list that is similar to the one in MemberInfo panel
const ChatCreateOrReuseDialog = sdk.getComponent( const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog");
"views.dialogs.ChatCreateOrReuseDialog",
);
const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, { const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, {
userId: addrTexts[0], userId: addrTexts[0],
onNewDMClick: () => { onNewDMClick: () => {
@ -115,6 +113,15 @@ function _onStartChatFinished(shouldInvite, addrs) {
}); });
}); });
} }
} else if (addrTexts.length === 1) {
// Start a new DM chat
createRoom({dmUserId: addrTexts[0]}).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, {
title: _t("Failed to invite user"),
description: ((err && err.message) ? err.message : _t("Operation failed")),
});
});
} else { } else {
// Start multi user chat // Start multi user chat
let room; let room;

View file

@ -34,7 +34,14 @@ export function getRoomNotifsState(roomId) {
} }
// for everything else, look at the room rule. // for everything else, look at the room rule.
const roomRule = MatrixClientPeg.get().getRoomPushRule('global', roomId); let roomRule = null;
try {
roomRule = MatrixClientPeg.get().getRoomPushRule('global', roomId);
} catch (err) {
// Possible that the client doesn't have pushRules yet. If so, it
// hasn't started eiher, so indicate that this room is not notifying.
return null;
}
// XXX: We have to assume the default is to notify for all messages // XXX: We have to assume the default is to notify for all messages
// (in particular this will be 'wrong' for one to one rooms because // (in particular this will be 'wrong' for one to one rooms because
@ -130,6 +137,11 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
} }
function findOverrideMuteRule(roomId) { function findOverrideMuteRule(roomId) {
if (!MatrixClientPeg.get().pushRules ||
!MatrixClientPeg.get().pushRules['global'] ||
!MatrixClientPeg.get().pushRules['global'].override) {
return null;
}
for (const rule of MatrixClientPeg.get().pushRules['global'].override) { for (const rule of MatrixClientPeg.get().pushRules['global'].override) {
if (isRuleForRoom(roomId, rule)) { if (isRuleForRoom(roomId, rule)) {
if (isMuteRule(rule) && rule.enabled) { if (isMuteRule(rule) && rule.enabled) {

View file

@ -43,7 +43,7 @@ export function getOnlyOtherMember(room, me) {
return null; return null;
} }
export function isConfCallRoom(room, me, conferenceHandler) { function _isConfCallRoom(room, me, conferenceHandler) {
if (!conferenceHandler) return false; if (!conferenceHandler) return false;
if (me.membership != "join") { if (me.membership != "join") {
@ -58,6 +58,26 @@ export function isConfCallRoom(room, me, conferenceHandler) {
if (conferenceHandler.isConferenceUser(otherMember.userId)) { if (conferenceHandler.isConferenceUser(otherMember.userId)) {
return true; return true;
} }
return false;
}
// Cache whether a room is a conference call. Assumes that rooms will always
// either will or will not be a conference call room.
const isConfCallRoomCache = {
// $roomId: bool
};
export function isConfCallRoom(room, me, conferenceHandler) {
if (isConfCallRoomCache[room.roomId] !== undefined) {
return isConfCallRoomCache[room.roomId];
}
const result = _isConfCallRoom(room, me, conferenceHandler);
isConfCallRoomCache[room.roomId] = result;
return result;
} }
export function looksLikeDirectMessageRoom(room, me) { export function looksLikeDirectMessageRoom(room, me) {

View file

@ -39,11 +39,53 @@ class ScalarAuthClient {
// Returns a scalar_token string // Returns a scalar_token string
getScalarToken() { getScalarToken() {
const tok = window.localStorage.getItem("mx_scalar_token"); const token = window.localStorage.getItem("mx_scalar_token");
if (tok) return Promise.resolve(tok);
// No saved token, so do the dance to get one. First, we if (!token) {
// need an openid bearer token from the HS. return this.registerForToken();
} else {
return this.validateToken(token).then(userId => {
const me = MatrixClientPeg.get().getUserId();
if (userId !== me) {
throw new Error("Scalar token is owned by someone else: " + me);
}
return token;
}).catch(err => {
console.error(err);
// Something went wrong - try to get a new token.
console.warn("Registering for new scalar token");
return this.registerForToken();
})
}
}
validateToken(token) {
let url = SdkConfig.get().integrations_rest_url + "/account";
const defer = Promise.defer();
request({
method: "GET",
uri: url,
qs: {scalar_token: token},
json: true,
}, (err, response, body) => {
if (err) {
defer.reject(err);
} else if (response.statusCode / 100 !== 2) {
defer.reject({statusCode: response.statusCode});
} else if (!body || !body.user_id) {
defer.reject(new Error("Missing user_id in response"));
} else {
defer.resolve(body.user_id);
}
});
return defer.promise;
}
registerForToken() {
// Get openid bearer token from the HS as the first part of our dance
return MatrixClientPeg.get().getOpenIdToken().then((token_object) => { return MatrixClientPeg.get().getOpenIdToken().then((token_object) => {
// Now we can send that to scalar and exchange it for a scalar token // Now we can send that to scalar and exchange it for a scalar token
return this.exchangeForScalarToken(token_object); return this.exchangeForScalarToken(token_object);
@ -106,10 +148,48 @@ class ScalarAuthClient {
return defer.promise; return defer.promise;
} }
getScalarInterfaceUrlForRoom(roomId, screen, id) { /**
* Mark all assets associated with the specified widget as "disabled" in the
* integration manager database.
* This can be useful to temporarily prevent purchased assets from being displayed.
* @param {string} widgetType [description]
* @param {string} widgetId [description]
* @return {Promise} Resolves on completion
*/
disableWidgetAssets(widgetType, widgetId) {
let url = SdkConfig.get().integrations_rest_url + '/widgets/set_assets_state';
url = this.getStarterLink(url);
return new Promise((resolve, reject) => {
request({
method: 'GET',
uri: url,
json: true,
qs: {
'widget_type': widgetType,
'widget_id': widgetId,
'state': 'disable',
},
}, (err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject({statusCode: response.statusCode});
} else if (!body) {
reject(new Error("Failed to set widget assets state"));
} else {
resolve();
}
});
});
}
getScalarInterfaceUrlForRoom(room, screen, id) {
const roomId = room.roomId;
const roomName = room.name;
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 += "&room_name=" + encodeURIComponent(roomName);
url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme")); url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme"));
if (id) { if (id) {
url += '&integ_id=' + encodeURIComponent(id); url += '&integ_id=' + encodeURIComponent(id);

View file

@ -235,6 +235,7 @@ const SdkConfig = require('./SdkConfig');
const MatrixClientPeg = require("./MatrixClientPeg"); const MatrixClientPeg = require("./MatrixClientPeg");
const MatrixEvent = require("matrix-js-sdk").MatrixEvent; const MatrixEvent = require("matrix-js-sdk").MatrixEvent;
const dis = require("./dispatcher"); const dis = require("./dispatcher");
const Widgets = require('./utils/widgets');
import { _t } from './languageHandler'; import { _t } from './languageHandler';
function sendResponse(event, res) { function sendResponse(event, res) {
@ -291,6 +292,7 @@ function setWidget(event, roomId) {
const widgetUrl = event.data.url; const widgetUrl = event.data.url;
const widgetName = event.data.name; // optional const widgetName = event.data.name; // optional
const widgetData = event.data.data; // optional const widgetData = event.data.data; // optional
const userWidget = event.data.userWidget;
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (!client) { if (!client) {
@ -330,10 +332,46 @@ function setWidget(event, roomId) {
name: widgetName, name: widgetName,
data: widgetData, data: widgetData,
}; };
if (userWidget) {
const client = MatrixClientPeg.get();
const userWidgets = Widgets.getUserWidgets();
// Delete existing widget with ID
try {
delete userWidgets[widgetId];
} catch (e) {
console.error(`$widgetId is non-configurable`);
}
// Add new widget / update
if (widgetUrl !== null) {
userWidgets[widgetId] = {
content: content,
sender: client.getUserId(),
stateKey: widgetId,
type: 'm.widget',
id: widgetId,
};
}
client.setAccountData('m.widgets', userWidgets).then(() => {
sendResponse(event, {
success: true,
});
dis.dispatch({ action: "user_widget_updated" });
});
} else { // Room widget
if (!roomId) {
sendError(event, _t('Missing roomId.'), null);
}
if (widgetUrl === null) { // widget is being deleted if (widgetUrl === null) { // widget is being deleted
content = {}; content = {};
} }
// TODO - Room widgets need to be moved to 'm.widget' state events
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => {
sendResponse(event, { sendResponse(event, {
success: true, success: true,
@ -342,6 +380,7 @@ function setWidget(event, roomId) {
sendError(event, _t('Failed to send request.'), err); sendError(event, _t('Failed to send request.'), err);
}); });
} }
}
function getWidgets(event, roomId) { function getWidgets(event, roomId) {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -349,19 +388,30 @@ function getWidgets(event, roomId) {
sendError(event, _t('You need to be logged in.')); sendError(event, _t('You need to be logged in.'));
return; return;
} }
let widgetStateEvents = [];
if (roomId) {
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
if (!room) { if (!room) {
sendError(event, _t('This room is not recognised.')); sendError(event, _t('This room is not recognised.'));
return; return;
} }
// TODO - Room widgets need to be moved to 'm.widget' state events
// https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing
const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets");
// Only return widgets which have required fields // Only return widgets which have required fields
const widgetStateEvents = []; if (room) {
stateEvents.forEach((ev) => { stateEvents.forEach((ev) => {
if (ev.getContent().type && ev.getContent().url) { if (ev.getContent().type && ev.getContent().url) {
widgetStateEvents.push(ev.event); // return the raw event widgetStateEvents.push(ev.event); // return the raw event
} }
}); });
}
}
// Add user widgets (not linked to a specific room)
const userWidgets = Widgets.getUserWidgetsArray();
widgetStateEvents = widgetStateEvents.concat(userWidgets);
sendResponse(event, widgetStateEvents); sendResponse(event, widgetStateEvents);
} }
@ -563,7 +613,7 @@ const onMessage = function(event) {
const url = SdkConfig.get().integrations_ui_url; const url = SdkConfig.get().integrations_ui_url;
if ( if (
event.origin.length === 0 || event.origin.length === 0 ||
!url.startsWith(event.origin) || !url.startsWith(event.origin + '/') ||
!event.data.action || !event.data.action ||
event.data.api // Ignore messages with specific API set event.data.api // Ignore messages with specific API set
) { ) {
@ -578,10 +628,23 @@ const onMessage = function(event) {
const roomId = event.data.room_id; const roomId = event.data.room_id;
const userId = event.data.user_id; const userId = event.data.user_id;
if (!roomId) { if (!roomId) {
// These APIs don't require roomId
// Get and set user widgets (not associated with a specific room)
// If roomId is specified, it must be validated, so room-based widgets agreed
// handled further down.
if (event.data.action === "get_widgets") {
getWidgets(event, null);
return;
} else if (event.data.action === "set_widget") {
setWidget(event, null);
return;
} else {
sendError(event, _t('Missing room_id in request')); sendError(event, _t('Missing room_id in request'));
return; return;
} }
}
let promise = Promise.resolve(currentRoomId); let promise = Promise.resolve(currentRoomId);
if (!currentRoomId) { if (!currentRoomId) {
if (!currentRoomAlias) { if (!currentRoomAlias) {
@ -601,6 +664,15 @@ const onMessage = function(event) {
return; return;
} }
// Get and set room-based widgets
if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
}
// These APIs don't require userId // These APIs don't require userId
if (event.data.action === "join_rules_state") { if (event.data.action === "join_rules_state") {
getJoinRules(event, roomId); getJoinRules(event, roomId);
@ -611,12 +683,6 @@ const onMessage = function(event) {
} else if (event.data.action === "get_membership_count") { } else if (event.data.action === "get_membership_count") {
getMembershipCount(event, roomId); getMembershipCount(event, roomId);
return; return;
} else if (event.data.action === "set_widget") {
setWidget(event, roomId);
return;
} else if (event.data.action === "get_widgets") {
getWidgets(event, roomId);
return;
} else if (event.data.action === "get_room_enc_state") { } else if (event.data.action === "get_room_enc_state") {
getRoomEncState(event, roomId); getRoomEncState(event, roomId);
return; return;

View file

@ -21,6 +21,13 @@ const DEFAULTS = {
integrations_rest_url: "https://scalar.vector.im/api", integrations_rest_url: "https://scalar.vector.im/api",
// Where to send bug reports. If not specified, bugs cannot be sent. // Where to send bug reports. If not specified, bugs cannot be sent.
bug_report_endpoint_url: null, bug_report_endpoint_url: null,
piwik: {
url: "https://piwik.riot.im/",
whitelistedHSUrls: ["https://matrix.org"],
whitelistedISUrls: ["https://vector.im", "https://matrix.org"],
siteId: 1,
},
}; };
class SdkConfig { class SdkConfig {
@ -45,3 +52,4 @@ class SdkConfig {
} }
module.exports = SdkConfig; module.exports = SdkConfig;
module.exports.DEFAULTS = DEFAULTS;

View file

@ -96,6 +96,8 @@ const commands = {
colorScheme.primary_color = matches[1]; colorScheme.primary_color = matches[1];
if (matches[4]) { if (matches[4]) {
colorScheme.secondary_color = matches[4]; colorScheme.secondary_color = matches[4];
} else {
colorScheme.secondary_color = colorScheme.primary_color;
} }
return success( return success(
SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme),
@ -295,7 +297,7 @@ const commands = {
// Define the power level of a user // Define the power level of a user
op: new Command("op", "<userId> [<power level>]", function(roomId, args) { op: new Command("op", "<userId> [<power level>]", function(roomId, args) {
if (args) { if (args) {
const matches = args.match(/^(\S+?)( +(\d+))?$/); const matches = args.match(/^(\S+?)( +(-?\d+))?$/);
let powerLevel = 50; // default power level for op let powerLevel = 50; // default power level for op
if (matches) { if (matches) {
const userId = matches[1]; const userId = matches[1];

View file

@ -52,8 +52,7 @@ function textForMemberEvent(ev) {
case 'join': case 'join':
if (prevContent && prevContent.membership === 'join') { if (prevContent && prevContent.membership === 'join') {
if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) {
return _t('%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.', { return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', {
senderName,
oldDisplayName: prevContent.displayname, oldDisplayName: prevContent.displayname,
displayName: content.displayname, displayName: content.displayname,
}); });

View file

@ -252,7 +252,6 @@ class Tinter {
setTheme(theme) { setTheme(theme) {
console.trace("setTheme " + theme);
this.theme = theme; this.theme = theme;
// update keyRgb from the current theme CSS itself, if it defines it // update keyRgb from the current theme CSS itself, if it defines it
@ -299,6 +298,7 @@ class Tinter {
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];
try {
if (!ss) continue; // well done safari >:( if (!ss) continue; // well done safari >:(
// Chromium apparently sometimes returns null here; unsure why. // Chromium apparently sometimes returns null here; unsure why.
// see $14534907369972FRXBx:matrix.org in HQ // see $14534907369972FRXBx:matrix.org in HQ
@ -327,7 +327,9 @@ class Tinter {
// Vector Green as any other colour. // Vector Green as any other colour.
// --matthew // --matthew
if (ss.href && !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue; // stylesheets we don't have permission to access (eg. ones from extensions) have a null
// href and will throw exceptions if we try to access their rules.
if (!ss.href || !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue;
if (ss.disabled) continue; if (ss.disabled) continue;
if (!ss.cssRules) continue; if (!ss.cssRules) continue;
@ -350,6 +352,13 @@ class Tinter {
} }
} }
} }
} catch (e) {
// Catch any random exceptions that happen here: all sorts of things can go
// wrong with this (nulls, SecurityErrors) and mostly it's for other
// stylesheets that we don't want to proces anyway. We should not propagate an
// exception out since this will cause the app to fail to start.
console.log("Failed to calculate CSS fixups for a stylesheet: " + ss.href, e);
}
} }
if (DEBUG) { if (DEBUG) {
console.log("calcCssFixups end (" + console.log("calcCssFixups end (" +

View file

@ -0,0 +1,86 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import Promise from "bluebird";
// const OUTBOUND_API_NAME = 'toWidget';
// Initiate requests using the "toWidget" postMessage API and handle responses
// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a
// response field
export default class ToWidgetPostMessageApi {
constructor(timeoutMs) {
this._timeoutMs = timeoutMs || 5000; // default to 5s timer
this._counter = 0;
this._requestMap = {
// $ID: {resolve, reject}
};
this.start = this.start.bind(this);
this.stop = this.stop.bind(this);
this.onPostMessage = this.onPostMessage.bind(this);
}
start() {
window.addEventListener('message', this.onPostMessage);
}
stop() {
window.removeEventListener('message', this.onPostMessage);
}
onPostMessage(ev) {
// THIS IS ALL UNSAFE EXECUTION.
// We do not verify who the sender of `ev` is!
const payload = ev.data;
// NOTE: Workaround for running in a mobile WebView where a
// postMessage immediately triggers this callback even though it is
// not the response.
if (payload.response === undefined) {
return;
}
const promise = this._requestMap[payload._id];
if (!promise) {
return;
}
delete this._requestMap[payload._id];
promise.resolve(payload);
}
// Initiate outbound requests (toWidget)
exec(action, targetWindow, targetOrigin) {
targetWindow = targetWindow || window.parent; // default to parent window
targetOrigin = targetOrigin || "*";
this._counter += 1;
action._id = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter;
return new Promise((resolve, reject) => {
this._requestMap[action._id] = {resolve, reject};
targetWindow.postMessage(action, targetOrigin);
if (this._timeoutMs > 0) {
setTimeout(() => {
if (!this._requestMap[action._id]) {
return;
}
console.error("postMessage request timed out. Sent object: " + JSON.stringify(action),
this._requestMap);
this._requestMap[action._id].reject(new Error("Timed out"));
delete this._requestMap[action._id];
}, this._timeoutMs);
}
});
}
}

View file

@ -28,6 +28,8 @@ module.exports = {
return false; return false;
} else if (ev.getType() == 'm.room.member') { } else if (ev.getType() == 'm.room.member') {
return false; return false;
} else if (ev.getType() == 'm.room.third_party_invite') {
return false;
} else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') {
return false; return false;
} else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { } else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') {

View file

@ -1,5 +1,6 @@
const React = require('react'); const React = require('react');
const ReactDom = require('react-dom'); const ReactDom = require('react-dom');
import PropTypes from 'prop-types';
const Velocity = require('velocity-vector'); const Velocity = require('velocity-vector');
/** /**
@ -14,16 +15,16 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// either a list of child nodes, or a single child. // either a list of child nodes, or a single child.
children: React.PropTypes.any, children: PropTypes.any,
// optional transition information for changing existing children // optional transition information for changing existing children
transition: React.PropTypes.object, transition: PropTypes.object,
// a list of state objects to apply to each child node in turn // a list of state objects to apply to each child node in turn
startStyles: React.PropTypes.array, startStyles: PropTypes.array,
// a list of transition options from the corresponding startStyle // a list of transition options from the corresponding startStyle
enterTransitionOpts: React.PropTypes.array, enterTransitionOpts: PropTypes.array,
}, },
getDefaultProps: function() { getDefaultProps: function() {

View file

@ -15,312 +15,91 @@ limitations under the License.
*/ */
/* /*
Listens for incoming postMessage requests from embedded widgets. The following API is exposed: * See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for
{ * spec. details / documentation.
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'; import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
const WIDGET_API_VERSION = '0.0.1'; // Current API version if (!global.mxFromWidgetMessaging) {
const SUPPORTED_WIDGET_API_VERSIONS = [ global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
'0.0.1', global.mxFromWidgetMessaging.start();
];
import dis from './dispatcher';
if (!global.mxWidgetMessagingListenerCount) {
global.mxWidgetMessagingListenerCount = 0;
} }
if (!global.mxWidgetMessagingMessageEndpoints) { if (!global.mxToWidgetMessaging) {
global.mxWidgetMessagingMessageEndpoints = []; global.mxToWidgetMessaging = new ToWidgetPostMessageApi();
global.mxToWidgetMessaging.start();
} }
const OUTBOUND_API_NAME = 'toWidget';
/** export default class WidgetMessaging {
* Register widget message event listeners constructor(widgetId, widgetUrl, target) {
*/
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.widgetId = widgetId;
this.endpointUrl = endpointUrl; this.widgetUrl = widgetUrl;
} this.target = target;
this.fromWidget = global.mxFromWidgetMessaging;
this.toWidget = global.mxToWidgetMessaging;
this.start();
} }
export default { messageToWidget(action) {
startListening: startListening, return this.toWidget.exec(action, this.target).then((data) => {
stopListening: stopListening, // Check for errors and reject if found
addEndpoint: addEndpoint, if (data.response === undefined) { // null is valid
removeEndpoint: removeEndpoint, throw new Error("Missing 'response' field");
}; }
if (data.response && data.response.error) {
const err = data.response.error;
const msg = String(err.message ? err.message : "An error was returned");
if (err._error) {
console.error(err._error);
}
// Potential XSS attack if 'msg' is not appropriately sanitized,
// as it is untrusted input by our parent window (which we assume is Riot).
// We can't aggressively sanitize [A-z0-9] since it might be a translation.
throw new Error(msg);
}
// Return the response field for the request
return data.response;
});
}
/**
* Request a screenshot from a widget
* @return {Promise} To be resolved with screenshot data when it has been generated
*/
getScreenshot() {
console.warn('Requesting screenshot for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "screenshot",
})
.catch((error) => new Error("Failed to get screenshot: " + error.message))
.then((response) => response.screenshot);
}
/**
* Request capabilities required by the widget
* @return {Promise} To be resolved with an array of requested widget capabilities
*/
getCapabilities() {
console.warn('Requesting capabilities for', this.widgetId);
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: "capabilities",
}).then((response) => {
console.warn('Got capabilities for', this.widgetId, response.capabilities);
return response.capabilities;
});
}
start() {
this.fromWidget.addEndpoint(this.widgetId, this.widgetUrl);
}
stop() {
this.fromWidget.removeEndpoint(this.widgetId, this.widgetUrl);
}
}

View file

@ -0,0 +1,37 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Represents mapping of widget instance to URLs for trusted postMessage communication.
*/
export default 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;
}
}

View file

@ -17,8 +17,8 @@ limitations under the License.
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
export default class WidgetUtils { export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room /* Returns true if user is able to send state events to modify widgets in this room
* (Does not apply to non-room-based / user widgets)
* @param roomId -- The ID of the room to check * @param roomId -- The ID of the room to check
* @return Boolean -- true if the user can modify widgets in this room * @return Boolean -- true if the user can modify widgets in this room
* @throws Error -- specifies the error reason * @throws Error -- specifies the error reason

View file

@ -62,6 +62,127 @@ function createAccountDataAction(matrixClient, accountDataEvent) {
}; };
} }
/**
* @typedef RoomAction
* @type {Object}
* @property {string} action 'MatrixActions.Room'.
* @property {Room} room the Room that was stored.
*/
/**
* Create a MatrixActions.Room action that represents a MatrixClient `Room`
* matrix event, emitted when a Room is stored in the client.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {Room} room the Room that was stored.
* @returns {RoomAction} an action of type `MatrixActions.Room`.
*/
function createRoomAction(matrixClient, room) {
return { action: 'MatrixActions.Room', room };
}
/**
* @typedef RoomTagsAction
* @type {Object}
* @property {string} action 'MatrixActions.Room.tags'.
* @property {Room} room the Room whose tags changed.
*/
/**
* Create a MatrixActions.Room.tags action that represents a MatrixClient
* `Room.tags` matrix event, emitted when the m.tag room account data
* event is updated.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} roomTagsEvent the m.tag event.
* @param {Room} room the Room whose tags were changed.
* @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`.
*/
function createRoomTagsAction(matrixClient, roomTagsEvent, room) {
return { action: 'MatrixActions.Room.tags', room };
}
/**
* @typedef RoomTimelineAction
* @type {Object}
* @property {string} action 'MatrixActions.Room.timeline'.
* @property {boolean} isLiveEvent whether the event was attached to a
* live timeline.
* @property {boolean} isLiveUnfilteredRoomTimelineEvent whether the
* event was attached to a timeline in the set of unfiltered timelines.
* @property {Room} room the Room whose tags changed.
*/
/**
* Create a MatrixActions.Room.timeline action that represents a
* MatrixClient `Room.timeline` matrix event, emitted when an event
* is added to or removed from a timeline of a room.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} timelineEvent the event that was added/removed.
* @param {Room} room the Room that was stored.
* @param {boolean} toStartOfTimeline whether the event is being added
* to the start (and not the end) of the timeline.
* @param {boolean} removed whether the event was removed from the
* timeline.
* @param {Object} data
* @param {boolean} data.liveEvent whether the event is a live event,
* belonging to a live timeline.
* @param {EventTimeline} data.timeline the timeline being altered.
* @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`.
*/
function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) {
return {
action: 'MatrixActions.Room.timeline',
event: timelineEvent,
isLiveEvent: data.liveEvent,
isLiveUnfilteredRoomTimelineEvent:
room && data.timeline.getTimelineSet() === room.getUnfilteredTimelineSet(),
};
}
/**
* @typedef RoomMembershipAction
* @type {Object}
* @property {string} action 'MatrixActions.RoomMember.membership'.
* @property {RoomMember} member the member whose membership was updated.
*/
/**
* Create a MatrixActions.RoomMember.membership action that represents
* a MatrixClient `RoomMember.membership` matrix event, emitted when a
* member's membership is updated.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} membershipEvent the m.room.member event.
* @param {RoomMember} member the member whose membership was updated.
* @param {string} oldMembership the member's previous membership.
* @returns {RoomMembershipAction} an action of type `MatrixActions.RoomMember.membership`.
*/
function createRoomMembershipAction(matrixClient, membershipEvent, member, oldMembership) {
return { action: 'MatrixActions.RoomMember.membership', member };
}
/**
* @typedef EventDecryptedAction
* @type {Object}
* @property {string} action 'MatrixActions.Event.decrypted'.
* @property {MatrixEvent} event the matrix event that was decrypted.
*/
/**
* Create a MatrixActions.Event.decrypted action that represents
* a MatrixClient `Event.decrypted` matrix event, emitted when a
* matrix event is decrypted.
*
* @param {MatrixClient} matrixClient the matrix client.
* @param {MatrixEvent} event the matrix event that was decrypted.
* @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`.
*/
function createEventDecryptedAction(matrixClient, event) {
return { action: 'MatrixActions.Event.decrypted', event };
}
/** /**
* This object is responsible for dispatching actions when certain events are emitted by * This object is responsible for dispatching actions when certain events are emitted by
* the given MatrixClient. * the given MatrixClient.
@ -78,6 +199,11 @@ export default {
start(matrixClient) { start(matrixClient) {
this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); this._addMatrixClientListener(matrixClient, 'sync', createSyncAction);
this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction);
this._addMatrixClientListener(matrixClient, 'Room', createRoomAction);
this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction);
this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction);
this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction);
this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction);
}, },
/** /**
@ -91,7 +217,7 @@ export default {
*/ */
_addMatrixClientListener(matrixClient, eventName, actionCreator) { _addMatrixClientListener(matrixClient, eventName, actionCreator) {
const listener = (...args) => { const listener = (...args) => {
dis.dispatch(actionCreator(matrixClient, ...args)); dis.dispatch(actionCreator(matrixClient, ...args), true);
}; };
matrixClient.on(eventName, listener); matrixClient.on(eventName, listener);
this._matrixClientListenersStop.push(() => { this._matrixClientListenersStop.push(() => {

View file

@ -0,0 +1,146 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { asyncAction } from './actionCreators';
import RoomListStore from '../stores/RoomListStore';
import Modal from '../Modal';
import * as Rooms from '../Rooms';
import { _t } from '../languageHandler';
import sdk from '../index';
const RoomListActions = {};
/**
* Creates an action thunk that will do an asynchronous request to
* tag room.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {Room} room the room to tag.
* @param {string} oldTag the tag to remove (unless oldTag ==== newTag)
* @param {string} newTag the tag with which to tag the room.
* @param {?number} oldIndex the previous position of the room in the
* list of rooms.
* @param {?number} newIndex the new position of the room in the list
* of rooms.
* @returns {function} an action thunk.
* @see asyncAction
*/
RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, newIndex) {
let metaData = null;
// Is the tag ordered manually?
if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
const lists = RoomListStore.getRoomLists();
const newList = [...lists[newTag]];
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
// If the room was moved "down" (increasing index) in the same list we
// need to use the orders of the tiles with indices shifted by +1
const offset = (
newTag === oldTag && oldIndex < newIndex
) ? 1 : 0;
const indexBefore = offset + newIndex - 1;
const indexAfter = offset + newIndex;
const prevOrder = indexBefore <= 0 ?
0 : newList[indexBefore].tags[newTag].order;
const nextOrder = indexAfter >= newList.length ?
1 : newList[indexAfter].tags[newTag].order;
metaData = {
order: (prevOrder + nextOrder) / 2.0,
};
}
return asyncAction('RoomListActions.tagRoom', () => {
const promises = [];
const roomId = room.roomId;
// Evil hack to get DMs behaving
if ((oldTag === undefined && newTag === 'im.vector.fake.direct') ||
(oldTag === 'im.vector.fake.direct' && newTag === undefined)
) {
return Rooms.guessAndSetDMRoom(
room, newTag === 'im.vector.fake.direct',
).catch((err) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to set direct chat tag " + err);
Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, {
title: _t('Failed to set direct chat tag'),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
}
const hasChangedSubLists = oldTag !== newTag;
// More evilness: We will still be dealing with moving to favourites/low prio,
// but we avoid ever doing a request with 'im.vector.fake.direct`.
//
// if we moved lists, remove the old tag
if (oldTag && oldTag !== 'im.vector.fake.direct' &&
hasChangedSubLists
) {
const promiseToDelete = matrixClient.deleteRoomTag(
roomId, oldTag,
).catch(function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to remove tag " + oldTag + " from room: " + err);
Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, {
title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
});
promises.push(promiseToDelete);
}
// if we moved lists or the ordering changed, add the new tag
if (newTag && newTag !== 'im.vector.fake.direct' &&
(hasChangedSubLists || metaData)
) {
// metaData is the body of the PUT to set the tag, so it must
// at least be an empty object.
metaData = metaData || {};
const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to add tag " + newTag + " to room: " + err);
Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, {
title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}),
description: ((err && err.message) ? err.message : _t('Operation failed')),
});
throw err;
});
promises.push(promiseToAdd);
}
return Promise.all(promises);
}, () => {
// For an optimistic update
return {
room, oldTag, newTag, metaData,
};
});
};
export default RoomListActions;

View file

@ -22,25 +22,87 @@ const TagOrderActions = {};
/** /**
* Creates an action thunk that will do an asynchronous request to * Creates an action thunk that will do an asynchronous request to
* commit TagOrderStore.getOrderedTags() to account data and dispatch * move a tag in TagOrderStore to destinationIx.
* actions to indicate the status of the request.
* *
* @param {MatrixClient} matrixClient the matrix client to set the * @param {MatrixClient} matrixClient the matrix client to set the
* account data on. * account data on.
* @param {string} tag the tag to move.
* @param {number} destinationIx the new position of the tag.
* @returns {function} an action thunk that will dispatch actions * @returns {function} an action thunk that will dispatch actions
* indicating the status of the request. * indicating the status of the request.
* @see asyncAction * @see asyncAction
*/ */
TagOrderActions.commitTagOrdering = function(matrixClient) { TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) {
return asyncAction('TagOrderActions.commitTagOrdering', () => {
// Only commit tags if the state is ready, i.e. not null // Only commit tags if the state is ready, i.e. not null
const tags = TagOrderStore.getOrderedTags(); let tags = TagOrderStore.getOrderedTags();
let removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
if (!tags) { if (!tags) {
return; return;
} }
tags = tags.filter((t) => t !== tag);
tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)];
removedTags = removedTags.filter((t) => t !== tag);
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.moveTag', () => {
Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); Analytics.trackEvent('TagOrderActions', 'commitTagOrdering');
return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags}); return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {tags, removedTags};
});
};
/**
* Creates an action thunk that will do an asynchronous request to
* label a tag as removed in im.vector.web.tag_ordering account data.
*
* The reason this is implemented with new state `removedTags` is that
* we incrementally and initially populate `tags` with groups that
* have been joined. If we remove a group from `tags`, it will just
* get added (as it looks like a group we've recently joined).
*
* NB: If we ever support adding of tags (which is planned), we should
* take special care to remove the tag from `removedTags` when we add
* it.
*
* @param {MatrixClient} matrixClient the matrix client to set the
* account data on.
* @param {string} tag the tag to remove.
* @returns {function} an action thunk that will dispatch actions
* indicating the status of the request.
* @see asyncAction
*/
TagOrderActions.removeTag = function(matrixClient, tag) {
// Don't change tags, just removedTags
const tags = TagOrderStore.getOrderedTags();
const removedTags = TagOrderStore.getRemovedTagsAccountData() || [];
if (removedTags.includes(tag)) {
// Return a thunk that doesn't do anything, we don't even need
// an asynchronous action here, the tag is already removed.
return () => {};
}
removedTags.push(tag);
const storeId = TagOrderStore.getStoreId();
return asyncAction('TagOrderActions.removeTag', () => {
Analytics.trackEvent('TagOrderActions', 'removeTag');
return matrixClient.setAccountData(
'im.vector.web.tag_ordering',
{tags, removedTags, _storeId: storeId},
);
}, () => {
// For an optimistic update
return {removedTags};
}); });
}; };

View file

@ -22,16 +22,32 @@ limitations under the License.
* suffix determining whether it is pending, successful or * suffix determining whether it is pending, successful or
* a failure. * a failure.
* @param {function} fn a function that returns a Promise. * @param {function} fn a function that returns a Promise.
* @param {function?} pendingFn a function that returns an object to assign
* to the `request` key of the ${id}.pending
* payload.
* @returns {function} an action thunk - a function that uses its single * @returns {function} an action thunk - a function that uses its single
* argument as a dispatch function to dispatch the * argument as a dispatch function to dispatch the
* following actions: * following actions:
* `${id}.pending` and either * `${id}.pending` and either
* `${id}.success` or * `${id}.success` or
* `${id}.failure`. * `${id}.failure`.
*
* The shape of each are:
* { action: '${id}.pending', request }
* { action: '${id}.success', result }
* { action: '${id}.failure', err }
*
* where `request` is returned by `pendingFn` and
* result is the result of the promise returned by
* `fn`.
*/ */
export function asyncAction(id, fn) { export function asyncAction(id, fn, pendingFn) {
return (dispatch) => { return (dispatch) => {
dispatch({action: id + '.pending'}); dispatch({
action: id + '.pending',
request:
typeof pendingFn === 'function' ? pendingFn() : undefined,
});
fn().then((result) => { fn().then((result) => {
dispatch({action: id + '.success', result}); dispatch({action: id + '.success', result});
}).catch((err) => { }).catch((err) => {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
const React = require("react"); const React = require("react");
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
const sdk = require('../../../index'); const sdk = require('../../../index');
const MatrixClientPeg = require("../../../MatrixClientPeg"); const MatrixClientPeg = require("../../../MatrixClientPeg");
@ -23,8 +24,8 @@ module.exports = React.createClass({
displayName: 'EncryptedEventDialog', displayName: 'EncryptedEventDialog',
propTypes: { propTypes: {
event: React.PropTypes.object.isRequired, event: PropTypes.object.isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -16,6 +16,7 @@ limitations under the License.
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as Matrix from 'matrix-js-sdk'; import * as Matrix from 'matrix-js-sdk';
@ -29,8 +30,8 @@ export default React.createClass({
displayName: 'ExportE2eKeysDialog', displayName: 'ExportE2eKeysDialog',
propTypes: { propTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as Matrix from 'matrix-js-sdk'; import * as Matrix from 'matrix-js-sdk';
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
@ -40,8 +41,8 @@ export default React.createClass({
displayName: 'ImportE2eKeysDialog', displayName: 'ImportE2eKeysDialog',
propTypes: { propTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -105,6 +105,11 @@ const COMMANDS = [
args: '<user-id>', args: '<user-id>',
description: _td('Stops ignoring a user, showing their messages going forward'), description: _td('Stops ignoring a user, showing their messages going forward'),
}, },
{
command: '/devtools',
args: '',
description: _td('Opens the Developer Tools dialog'),
},
// Omitting `/markdown` as it only seems to apply to OldComposer // Omitting `/markdown` as it only seems to apply to OldComposer
]; ];

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
/* These were earlier stateless functional components but had to be converted /* These were earlier stateless functional components but had to be converted
@ -42,10 +43,10 @@ export class TextualCompletion extends React.Component {
} }
} }
TextualCompletion.propTypes = { TextualCompletion.propTypes = {
title: React.PropTypes.string, title: PropTypes.string,
subtitle: React.PropTypes.string, subtitle: PropTypes.string,
description: React.PropTypes.string, description: PropTypes.string,
className: React.PropTypes.string, className: PropTypes.string,
}; };
export class PillCompletion extends React.Component { export class PillCompletion extends React.Component {
@ -69,9 +70,9 @@ export class PillCompletion extends React.Component {
} }
} }
PillCompletion.propTypes = { PillCompletion.propTypes = {
title: React.PropTypes.string, title: PropTypes.string,
subtitle: React.PropTypes.string, subtitle: PropTypes.string,
description: React.PropTypes.string, description: PropTypes.string,
initialComponent: React.PropTypes.element, initialComponent: PropTypes.element,
className: React.PropTypes.string, className: PropTypes.string,
}; };

View file

@ -25,6 +25,7 @@ import {PillCompletion} from './Components';
import {getDisplayAliasForRoom} from '../Rooms'; import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index'; import sdk from '../index';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../matrix-to";
const ROOM_REGEX = /(?=#)(\S*)/g; const ROOM_REGEX = /(?=#)(\S*)/g;
@ -78,7 +79,7 @@ export default class RoomProvider extends AutocompleteProvider {
return { return {
completion: displayAlias, completion: displayAlias,
suffix: ' ', suffix: ' ',
href: 'https://matrix.to/#/' + displayAlias, href: makeRoomPermalink(displayAlias),
component: ( component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} /> <PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={room.room} />} title={room.name} description={displayAlias} />
), ),

View file

@ -28,6 +28,7 @@ import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk'; import type {Room, RoomMember} from 'matrix-js-sdk';
import {makeUserPermalink} from "../matrix-to";
const USER_REGEX = /@\S*/g; const USER_REGEX = /@\S*/g;
@ -43,6 +44,7 @@ export default class UserProvider extends AutocompleteProvider {
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
shouldMatchPrefix: true, shouldMatchPrefix: true,
shouldMatchWordsOnly: false
}); });
this._onRoomTimelineBound = this._onRoomTimeline.bind(this); this._onRoomTimelineBound = this._onRoomTimeline.bind(this);
@ -71,6 +73,7 @@ export default class UserProvider extends AutocompleteProvider {
// updates from pagination will happen when the paginate completes. // updates from pagination will happen when the paginate completes.
if (toStartOfTimeline || !data || !data.liveEvent) return; if (toStartOfTimeline || !data || !data.liveEvent) return;
// TODO: lazyload if we have no ev.sender room member?
this.onUserSpoke(ev.sender); this.onUserSpoke(ev.sender);
} }
@ -106,7 +109,7 @@ export default class UserProvider extends AutocompleteProvider {
// relies on the length of the entity === length of the text in the decoration. // relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''), completion: user.rawDisplayName.replace(' (IRC)', ''),
suffix: range.start === 0 ? ': ' : ' ', suffix: range.start === 0 ? ': ' : ' ',
href: 'https://matrix.to/#/' + user.userId, href: makeUserPermalink(user.userId),
component: ( component: (
<PillCompletion <PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24} />} initialComponent={<MemberAvatar member={user} width={24} height={24} />}
@ -146,6 +149,7 @@ export default class UserProvider extends AutocompleteProvider {
onUserSpoke(user: RoomMember) { onUserSpoke(user: RoomMember) {
if (this.users === null) return; if (this.users === null) return;
if (!user) return;
if (user.userId === MatrixClientPeg.get().credentials.userId) return; if (user.userId === MatrixClientPeg.get().credentials.userId) return;
// Move the user that spoke to the front of the array // Move the user that spoke to the front of the array
@ -157,7 +161,7 @@ export default class UserProvider extends AutocompleteProvider {
} }
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">
{ completions } { completions }
</div>; </div>;
} }

View file

@ -20,6 +20,7 @@ limitations under the License.
const classNames = require('classnames'); const classNames = require('classnames');
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
import PropTypes from 'prop-types';
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
@ -29,11 +30,21 @@ module.exports = {
ContextualMenuContainerId: "mx_ContextualMenu_Container", ContextualMenuContainerId: "mx_ContextualMenu_Container",
propTypes: { propTypes: {
menuWidth: React.PropTypes.number, top: PropTypes.number,
menuHeight: React.PropTypes.number, bottom: PropTypes.number,
chevronOffset: React.PropTypes.number, left: PropTypes.number,
menuColour: React.PropTypes.string, right: PropTypes.number,
chevronFace: React.PropTypes.string, // top, bottom, left, right menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
menuColour: PropTypes.string,
chevronFace: PropTypes.string, // top, bottom, left, right
// Function to be called on menu close
onFinished: PropTypes.func,
menuPaddingTop: PropTypes.number,
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
}, },
getOrCreateContainer: function() { getOrCreateContainer: function() {
@ -51,14 +62,19 @@ module.exports = {
createMenu: function(Element, props) { createMenu: function(Element, props) {
const self = this; const self = this;
const closeMenu = function() { const closeMenu = function(...args) {
ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
if (props && props.onFinished) { if (props && props.onFinished) {
props.onFinished.apply(null, arguments); props.onFinished.apply(null, args);
} }
}; };
// Close the menu on window resize
const windowResize = function() {
closeMenu();
};
const position = {}; const position = {};
let chevronFace = null; let chevronFace = null;
@ -129,13 +145,26 @@ module.exports = {
menuStyle["backgroundColor"] = props.menuColour; menuStyle["backgroundColor"] = props.menuColour;
} }
if (!isNaN(Number(props.menuPaddingTop))) {
menuStyle["paddingTop"] = props.menuPaddingTop;
}
if (!isNaN(Number(props.menuPaddingLeft))) {
menuStyle["paddingLeft"] = props.menuPaddingLeft;
}
if (!isNaN(Number(props.menuPaddingBottom))) {
menuStyle["paddingBottom"] = props.menuPaddingBottom;
}
if (!isNaN(Number(props.menuPaddingRight))) {
menuStyle["paddingRight"] = props.menuPaddingRight;
}
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished // FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click! // property set here so you can't close the menu from a button click!
const menu = ( const menu = (
<div className={className} style={position}> <div className={className} style={position}>
<div className={menuClasses} style={menuStyle}> <div className={menuClasses} style={menuStyle}>
{ chevron } { chevron }
<Element {...props} onFinished={closeMenu} /> <Element {...props} onFinished={closeMenu} onResize={windowResize} />
</div> </div>
<div className="mx_ContextualMenu_background" onClick={closeMenu}></div> <div className="mx_ContextualMenu_background" onClick={closeMenu}></div>
<style>{ chevronCSS }</style> <style>{ chevronCSS }</style>

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import sdk from '../../index'; import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
@ -30,8 +31,8 @@ module.exports = React.createClass({
displayName: 'CreateRoom', displayName: 'CreateRoom',
propTypes: { propTypes: {
onRoomCreated: React.PropTypes.func, onRoomCreated: PropTypes.func,
collapsedRhs: React.PropTypes.bool, collapsedRhs: PropTypes.bool,
}, },
phases: { phases: {

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import sdk from '../../index'; import sdk from '../../index';
@ -28,7 +29,7 @@ const FilePanel = React.createClass({
displayName: 'FilePanel', displayName: 'FilePanel',
propTypes: { propTypes: {
roomId: React.PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -67,6 +68,9 @@ const FilePanel = React.createClass({
"room": { "room": {
"timeline": { "timeline": {
"contains_url": true, "contains_url": true,
"not_types": [
"m.sticker",
],
}, },
}, },
}, },

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2017 Vector Creations Ltd. Copyright 2017 Vector Creations Ltd.
Copyright 2017 New Vector Ltd. Copyright 2017, 2018 New Vector Ltd.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -29,8 +29,9 @@ 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 FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GeminiScrollbar from 'react-gemini-scrollbar'; import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to";
const LONG_DESC_PLACEHOLDER = _td( const LONG_DESC_PLACEHOLDER = _td(
`<h1>HTML for your community's page</h1> `<h1>HTML for your community's page</h1>
@ -209,7 +210,7 @@ const FeaturedRoom = React.createClass({
let permalink = null; let permalink = null;
if (this.props.summaryInfo.profile && this.props.summaryInfo.profile.canonical_alias) { if (this.props.summaryInfo.profile && this.props.summaryInfo.profile.canonical_alias) {
permalink = 'https://matrix.to/#/' + this.props.summaryInfo.profile.canonical_alias; permalink = makeGroupPermalink(this.props.summaryInfo.profile.canonical_alias);
} }
let roomNameNode = null; let roomNameNode = null;
@ -366,7 +367,7 @@ const FeaturedUser = React.createClass({
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id; const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id;
const permalink = 'https://matrix.to/#/' + this.props.summaryInfo.user_id; const permalink = makeUserPermalink(this.props.summaryInfo.user_id);
const userNameNode = <a href={permalink} onClick={this.onClick}>{ name }</a>; const userNameNode = <a href={permalink} onClick={this.onClick}>{ name }</a>;
const httpUrl = MatrixClientPeg.get() const httpUrl = MatrixClientPeg.get()
.mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64);
@ -390,7 +391,7 @@ const FeaturedUser = React.createClass({
}); });
const GroupContext = { const GroupContext = {
groupStore: React.PropTypes.instanceOf(GroupStore).isRequired, groupStore: PropTypes.instanceOf(GroupStore).isRequired,
}; };
CategoryRoomList.contextTypes = GroupContext; CategoryRoomList.contextTypes = GroupContext;
@ -398,6 +399,9 @@ FeaturedRoom.contextTypes = GroupContext;
RoleUserList.contextTypes = GroupContext; RoleUserList.contextTypes = GroupContext;
FeaturedUser.contextTypes = GroupContext; FeaturedUser.contextTypes = GroupContext;
const GROUP_JOINPOLICY_OPEN = "open";
const GROUP_JOINPOLICY_INVITE = "invite";
export default React.createClass({ export default React.createClass({
displayName: 'GroupView', displayName: 'GroupView',
@ -408,7 +412,7 @@ export default React.createClass({
}, },
childContextTypes: { childContextTypes: {
groupStore: React.PropTypes.instanceOf(GroupStore), groupStore: PropTypes.instanceOf(GroupStore),
}, },
getChildContext: function() { getChildContext: function() {
@ -428,6 +432,7 @@ export default React.createClass({
editing: false, editing: false,
saving: false, saving: false,
uploadingAvatar: false, uploadingAvatar: false,
avatarChanged: false,
membershipBusy: false, membershipBusy: false,
publicityBusy: false, publicityBusy: false,
inviterProfile: null, inviterProfile: null,
@ -461,6 +466,10 @@ export default React.createClass({
_onGroupMyMembership: function(group) { _onGroupMyMembership: function(group) {
if (group.groupId !== this.props.groupId) return; if (group.groupId !== this.props.groupId) return;
if (group.myMembership === 'leave') {
// Leave settings - the user might have clicked the "Leave" button
this._closeSettings();
}
this.setState({membershipBusy: false}); this.setState({membershipBusy: false});
}, },
@ -543,6 +552,12 @@ export default React.createClass({
this.setState({ this.setState({
editing: true, editing: true,
profileForm: Object.assign({}, this.state.summary.profile), profileForm: Object.assign({}, this.state.summary.profile),
joinableForm: {
policyType:
this.state.summary.profile.is_openly_joinable ?
GROUP_JOINPOLICY_OPEN :
GROUP_JOINPOLICY_INVITE,
},
}); });
dis.dispatch({ dis.dispatch({
action: 'panel_disable', action: 'panel_disable',
@ -551,6 +566,10 @@ export default React.createClass({
}, },
_onCancelClick: function() { _onCancelClick: function() {
this._closeSettings();
},
_closeSettings() {
this.setState({ this.setState({
editing: false, editing: false,
profileForm: null, profileForm: null,
@ -589,6 +608,10 @@ export default React.createClass({
this.setState({ this.setState({
uploadingAvatar: false, uploadingAvatar: false,
profileForm: newProfileForm, profileForm: newProfileForm,
// Indicate that FlairStore needs to be poked to show this change
// in TagTile (TagPanel), Flair and GroupTile (MyGroups).
avatarChanged: true,
}); });
}).catch((e) => { }).catch((e) => {
this.setState({uploadingAvatar: false}); this.setState({uploadingAvatar: false});
@ -601,11 +624,15 @@ export default React.createClass({
}).done(); }).done();
}, },
_onJoinableChange: function(ev) {
this.setState({
joinableForm: { policyType: ev.target.value },
});
},
_onSaveClick: function() { _onSaveClick: function() {
this.setState({saving: true}); this.setState({saving: true});
const savePromise = this.state.isUserPrivileged ? const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve();
this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm) :
Promise.resolve();
savePromise.then((result) => { savePromise.then((result) => {
this.setState({ this.setState({
saving: false, saving: false,
@ -614,6 +641,11 @@ export default React.createClass({
}); });
dis.dispatch({action: 'panel_disable'}); dis.dispatch({action: 'panel_disable'});
this._initGroupStore(this.props.groupId); this._initGroupStore(this.props.groupId);
if (this.state.avatarChanged) {
// XXX: Evil - poking a store should be done from an async action
FlairStore.refreshGroupProfile(this._matrixClient, this.props.groupId);
}
}).catch((e) => { }).catch((e) => {
this.setState({ this.setState({
saving: false, saving: false,
@ -624,11 +656,27 @@ export default React.createClass({
title: _t('Error'), title: _t('Error'),
description: _t('Failed to update community'), description: _t('Failed to update community'),
}); });
}).finally(() => {
this.setState({
avatarChanged: false,
});
}).done(); }).done();
}, },
_onAcceptInviteClick: function() { _saveGroup: async function() {
await this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm);
await this._matrixClient.setGroupJoinPolicy(this.props.groupId, {
type: this.state.joinableForm.policyType,
});
},
_onAcceptInviteClick: async function() {
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
this._groupStore.acceptGroupInvite().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) => {
@ -641,9 +689,14 @@ export default React.createClass({
}); });
}, },
_onRejectInviteClick: function() { _onRejectInviteClick: async function() {
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
this._matrixClient.leaveGroup(this.props.groupId).then(() => {
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
this._groupStore.leaveGroup().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});
@ -655,6 +708,25 @@ export default React.createClass({
}); });
}, },
_onJoinClick: async function() {
this.setState({membershipBusy: true});
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
this._groupStore.joinGroup().then(() => {
// don't reset membershipBusy here: wait for the membership change to come down the sync
}).catch((e) => {
this.setState({membershipBusy: false});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error joining room', '', ErrorDialog, {
title: _t("Error"),
description: _t("Unable to join community"),
});
});
},
_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, {
@ -662,18 +734,23 @@ export default React.createClass({
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,
onFinished: (confirmed) => { onFinished: async (confirmed) => {
if (!confirmed) return; if (!confirmed) return;
this.setState({membershipBusy: true}); this.setState({membershipBusy: true});
this._matrixClient.leaveGroup(this.props.groupId).then(() => {
// Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the
// spinner disappearing after we have fetched new group data.
await Promise.delay(500);
this._groupStore.leaveGroup().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});
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error leaving room', '', ErrorDialog, { Modal.createTrackedDialog('Error leaving community', '', ErrorDialog, {
title: _t("Error"), title: _t("Error"),
description: _t("Unable to leave room"), description: _t("Unable to leave community"),
}); });
}); });
}, },
@ -691,8 +768,22 @@ export default React.createClass({
}); });
const header = this.state.editing ? <h2> { _t('Community Settings') } </h2> : <div />; const header = this.state.editing ? <h2> { _t('Community Settings') } </h2> : <div />;
const changeDelayWarning = this.state.editing && this.state.isUserPrivileged ?
<div className="mx_GroupView_changeDelayWarning">
{ _t(
'Changes made to your community <bold1>name</bold1> and <bold2>avatar</bold2> ' +
'might not be seen by other users for up to 30 minutes.',
{},
{
'bold1': (sub) => <b> { sub } </b>,
'bold2': (sub) => <b> { sub } </b>,
},
) }
</div> : <div />;
return <div className={groupSettingsSectionClasses}> return <div className={groupSettingsSectionClasses}>
{ header } { header }
{ changeDelayWarning }
{ this._getJoinableNode() }
{ this._getLongDescriptionNode() } { this._getLongDescriptionNode() }
{ this._getRoomsNode() } { this._getRoomsNode() }
</div>; </div>;
@ -831,9 +922,8 @@ export default React.createClass({
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
const group = this._matrixClient.getGroup(this.props.groupId); const group = this._matrixClient.getGroup(this.props.groupId);
if (!group) return null;
if (group.myMembership === 'invite') { if (group && group.myMembership === 'invite') {
if (this.state.membershipBusy || this.state.inviterProfileBusy) { if (this.state.membershipBusy || this.state.inviterProfileBusy) {
return <div className="mx_GroupView_membershipSection"> return <div className="mx_GroupView_membershipSection">
<Spinner /> <Spinner />
@ -874,33 +964,107 @@ export default React.createClass({
</div> </div>
</div> </div>
</div>; </div>;
} else if (group.myMembership === 'join' && this.state.editing) { }
const leaveButtonTooltip = this.state.isUserPrivileged ?
let membershipContainerExtraClasses;
let membershipButtonExtraClasses;
let membershipButtonTooltip;
let membershipButtonText;
let membershipButtonOnClick;
// User is not in the group
if ((!group || group.myMembership === 'leave') &&
this.state.summary &&
this.state.summary.profile &&
Boolean(this.state.summary.profile.is_openly_joinable)
) {
membershipButtonText = _t("Join this community");
membershipButtonOnClick = this._onJoinClick;
membershipButtonExtraClasses = 'mx_GroupView_joinButton';
membershipContainerExtraClasses = 'mx_GroupView_membershipSection_leave';
} else if (
group &&
group.myMembership === 'join' &&
this.state.editing
) {
membershipButtonText = _t("Leave this community");
membershipButtonOnClick = this._onLeaveClick;
membershipButtonTooltip = this.state.isUserPrivileged ?
_t("You are an administrator of this community") : _t("You are an administrator of this community") :
_t("You are a member of this community"); _t("You are a member of this community");
const leaveButtonClasses = classnames({
"mx_RoomHeader_textButton": true, membershipButtonExtraClasses = {
"mx_GroupView_textButton": true, 'mx_GroupView_leaveButton': true,
"mx_GroupView_leaveButton": true, 'mx_RoomHeader_textButton_danger': this.state.isUserPrivileged,
"mx_RoomHeader_textButton_danger": this.state.isUserPrivileged, };
}); membershipContainerExtraClasses = 'mx_GroupView_membershipSection_joined';
return <div className="mx_GroupView_membershipSection mx_GroupView_membershipSection_joined"> } else {
return null;
}
const membershipButtonClasses = classnames([
'mx_RoomHeader_textButton',
'mx_GroupView_textButton',
],
membershipButtonExtraClasses,
);
const membershipContainerClasses = classnames(
'mx_GroupView_membershipSection',
membershipContainerExtraClasses,
);
return <div className={membershipContainerClasses}>
<div className="mx_GroupView_membershipSubSection"> <div className="mx_GroupView_membershipSubSection">
{ /* Empty div for flex alignment */ } { /* The <div /> is for flex alignment */ }
<div /> { this.state.membershipBusy ? <Spinner /> : <div /> }
<div className="mx_GroupView_membership_buttonContainer"> <div className="mx_GroupView_membership_buttonContainer">
<AccessibleButton <AccessibleButton
className={leaveButtonClasses} className={membershipButtonClasses}
onClick={this._onLeaveClick} onClick={membershipButtonOnClick}
title={leaveButtonTooltip} title={membershipButtonTooltip}
> >
{ _t("Leave") } { membershipButtonText }
</AccessibleButton> </AccessibleButton>
</div> </div>
</div> </div>
</div>; </div>;
},
_getJoinableNode: function() {
return this.state.editing ? <div>
<h3>
{ _t('Who can join this community?') }
{ this.state.groupJoinableLoading ?
<InlineSpinner /> : <div />
} }
return null; </h3>
<div>
<label>
<input type="radio"
value={GROUP_JOINPOLICY_INVITE}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_INVITE}
onClick={this._onJoinableChange}
/>
<div className="mx_GroupView_label_text">
{ _t('Only people who have been invited') }
</div>
</label>
</div>
<div>
<label>
<input type="radio"
value={GROUP_JOINPOLICY_OPEN}
checked={this.state.joinableForm.policyType === GROUP_JOINPOLICY_OPEN}
onClick={this._onJoinableChange}
/>
<div className="mx_GroupView_label_text">
{ _t('Everyone') }
</div>
</label>
</div>
</div> : null;
}, },
_getLongDescriptionNode: function() { _getLongDescriptionNode: function() {
@ -946,6 +1110,7 @@ export default React.createClass({
const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const GroupAvatar = sdk.getComponent("avatars.GroupAvatar");
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.state.summaryLoading && this.state.error === null || this.state.saving) { if (this.state.summaryLoading && this.state.error === null || this.state.saving) {
return <Spinner />; return <Spinner />;
@ -1096,9 +1261,9 @@ export default React.createClass({
{ rightButtons } { rightButtons }
</div> </div>
</div> </div>
<GeminiScrollbar className="mx_GroupView_body"> <GeminiScrollbarWrapper className="mx_GroupView_body">
{ bodyNodes } { bodyNodes }
</GeminiScrollbar> </GeminiScrollbarWrapper>
</div> </div>
); );
} else if (this.state.error) { } else if (this.state.error) {

View file

@ -18,6 +18,7 @@ import Matrix from 'matrix-js-sdk';
const InteractiveAuth = Matrix.InteractiveAuth; const InteractiveAuth = Matrix.InteractiveAuth;
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {getEntryComponentForLoginType} from '../views/login/InteractiveAuthEntryComponents'; import {getEntryComponentForLoginType} from '../views/login/InteractiveAuthEntryComponents';
@ -26,18 +27,18 @@ export default React.createClass({
propTypes: { propTypes: {
// matrix client to use for UI auth requests // matrix client to use for UI auth requests
matrixClient: React.PropTypes.object.isRequired, matrixClient: PropTypes.object.isRequired,
// response from initial request. If not supplied, will do a request on // response from initial request. If not supplied, will do a request on
// mount. // mount.
authData: React.PropTypes.shape({ authData: PropTypes.shape({
flows: React.PropTypes.array, flows: PropTypes.array,
params: React.PropTypes.object, params: PropTypes.object,
session: React.PropTypes.string, session: PropTypes.string,
}), }),
// callback // callback
makeRequest: React.PropTypes.func.isRequired, makeRequest: PropTypes.func.isRequired,
// callback called when the auth process has finished, // callback called when the auth process has finished,
// successfully or unsuccessfully. // successfully or unsuccessfully.
@ -51,22 +52,22 @@ export default React.createClass({
// the auth session. // the auth session.
// * clientSecret {string} The client secret used in auth // * clientSecret {string} The client secret used in auth
// sessions with the ID server. // sessions with the ID server.
onAuthFinished: React.PropTypes.func.isRequired, onAuthFinished: PropTypes.func.isRequired,
// Inputs provided by the user to the auth process // Inputs provided by the user to the auth process
// and used by various stages. As passed to js-sdk // and used by various stages. As passed to js-sdk
// interactive-auth // interactive-auth
inputs: React.PropTypes.object, inputs: PropTypes.object,
// As js-sdk interactive-auth // As js-sdk interactive-auth
makeRegistrationUrl: React.PropTypes.func, makeRegistrationUrl: PropTypes.func,
sessionId: React.PropTypes.string, sessionId: PropTypes.string,
clientSecret: React.PropTypes.string, clientSecret: PropTypes.string,
emailSid: React.PropTypes.string, emailSid: PropTypes.string,
// If true, poll to see if the auth flow has been completed // If true, poll to see if the auth flow has been completed
// out-of-band // out-of-band
poll: React.PropTypes.bool, poll: PropTypes.bool,
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -18,8 +18,8 @@ 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 PropTypes from 'prop-types';
import HTML5Backend from 'react-dnd-html5-backend'; import { DragDropContext } from 'react-beautiful-dnd';
import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard';
import Notifier from '../../Notifier'; import Notifier from '../../Notifier';
@ -31,6 +31,9 @@ import sessionStore from '../../stores/SessionStore';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import TagOrderActions from '../../actions/TagOrderActions';
import RoomListActions from '../../actions/RoomListActions';
/** /**
* This is what our MatrixChat shows when we are logged in. The precise view is * This is what our MatrixChat shows when we are logged in. The precise view is
* determined by the page_type property. * determined by the page_type property.
@ -44,23 +47,23 @@ const LoggedInView = React.createClass({
displayName: 'LoggedInView', displayName: 'LoggedInView',
propTypes: { propTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
page_type: React.PropTypes.string.isRequired, page_type: PropTypes.string.isRequired,
onRoomCreated: React.PropTypes.func, onRoomCreated: PropTypes.func,
onUserSettingsClose: React.PropTypes.func, onUserSettingsClose: PropTypes.func,
// Called with the credentials of a registered user (if they were a ROU that // Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU) // transitioned to PWLU)
onRegistered: React.PropTypes.func, onRegistered: PropTypes.func,
teamToken: React.PropTypes.string, teamToken: PropTypes.string,
// and lots and lots of other stuff. // and lots and lots of other stuff.
}, },
childContextTypes: { childContextTypes: {
matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient), matrixClient: PropTypes.instanceOf(Matrix.MatrixClient),
authCache: React.PropTypes.object, authCache: PropTypes.object,
}, },
getChildContext: function() { getChildContext: function() {
@ -208,8 +211,51 @@ const LoggedInView = React.createClass({
} }
}, },
_onDragEnd: function(result) {
// Dragged to an invalid destination, not onto a droppable
if (!result.destination) {
return;
}
const dest = result.destination.droppableId;
if (dest === 'tag-panel-droppable') {
// Could be "GroupTile +groupId:domain"
const draggableId = result.draggableId.split(' ').pop();
// Dispatch synchronously so that the TagPanel receives an
// optimistic update from TagOrderStore before the previous
// state is shown.
dis.dispatch(TagOrderActions.moveTag(
this._matrixClient,
draggableId,
result.destination.index,
), true);
} else if (dest.startsWith('room-sub-list-droppable_')) {
this._onRoomTileEndDrag(result);
}
},
_onRoomTileEndDrag: function(result) {
let newTag = result.destination.droppableId.split('_')[1];
let prevTag = result.source.droppableId.split('_')[1];
if (newTag === 'undefined') newTag = undefined;
if (prevTag === 'undefined') prevTag = undefined;
const roomId = result.draggableId.split('_')[1];
const oldIndex = result.source.index;
const newIndex = result.destination.index;
dis.dispatch(RoomListActions.tagRoom(
this._matrixClient,
this._matrixClient.getRoom(roomId),
prevTag, newTag,
oldIndex, newIndex,
), true);
},
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');
@ -328,12 +374,11 @@ const LoggedInView = React.createClass({
} }
return ( return (
<div className='mx_MatrixChat_wrapper'> <div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers}>
{ topBar } { topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div className={bodyClasses}> <div className={bodyClasses}>
{ SettingsStore.isFeatureEnabled("feature_tag_panel") ? <TagPanel /> : <div /> }
<LeftPanel <LeftPanel
selectedRoom={this.props.currentRoomId}
collapsed={this.props.collapseLhs || false} collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled} disabled={this.props.leftDisabled}
/> />
@ -342,9 +387,10 @@ const LoggedInView = React.createClass({
</main> </main>
{ right_panel } { right_panel }
</div> </div>
</DragDropContext>
</div> </div>
); );
}, },
}); });
export default DragDropContext(HTML5Backend)(LoggedInView); export default LoggedInView;

View file

@ -19,6 +19,7 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import Matrix from "matrix-js-sdk"; import Matrix from "matrix-js-sdk";
import Analytics from "../../Analytics"; import Analytics from "../../Analytics";
@ -92,38 +93,38 @@ export default React.createClass({
displayName: 'MatrixChat', displayName: 'MatrixChat',
propTypes: { propTypes: {
config: React.PropTypes.object, config: PropTypes.object,
ConferenceHandler: React.PropTypes.any, ConferenceHandler: PropTypes.any,
onNewScreen: React.PropTypes.func, onNewScreen: PropTypes.func,
registrationUrl: React.PropTypes.string, registrationUrl: PropTypes.string,
enableGuest: React.PropTypes.bool, enableGuest: PropTypes.bool,
// the queryParams extracted from the [real] query-string of the URI // the queryParams extracted from the [real] query-string of the URI
realQueryParams: React.PropTypes.object, realQueryParams: PropTypes.object,
// the initial queryParams extracted from the hash-fragment of the URI // the initial queryParams extracted from the hash-fragment of the URI
startingFragmentQueryParams: React.PropTypes.object, startingFragmentQueryParams: PropTypes.object,
// called when we have completed a token login // called when we have completed a token login
onTokenLoginCompleted: React.PropTypes.func, onTokenLoginCompleted: PropTypes.func,
// Represents the screen to display as a result of parsing the initial // Represents the screen to display as a result of parsing the initial
// window.location // window.location
initialScreenAfterLogin: React.PropTypes.shape({ initialScreenAfterLogin: PropTypes.shape({
screen: React.PropTypes.string.isRequired, screen: PropTypes.string.isRequired,
params: React.PropTypes.object, params: PropTypes.object,
}), }),
// displayname, if any, to set on the device when logging // displayname, if any, to set on the device when logging
// in/registering. // in/registering.
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: PropTypes.string,
// A function that makes a registration URL // A function that makes a registration URL
makeRegistrationUrl: React.PropTypes.func.isRequired, makeRegistrationUrl: PropTypes.func.isRequired,
}, },
childContextTypes: { childContextTypes: {
appConfig: React.PropTypes.object, appConfig: PropTypes.object,
}, },
AuxPanel: { AuxPanel: {
@ -170,6 +171,10 @@ export default React.createClass({
register_hs_url: null, register_hs_url: null,
register_is_url: null, register_is_url: null,
register_id_sid: null, register_id_sid: null,
// When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs
hideToSRUsers: false,
}; };
return s; return s;
}, },
@ -286,6 +291,8 @@ export default React.createClass({
this.handleResize(); this.handleResize();
window.addEventListener('resize', this.handleResize); window.addEventListener('resize', this.handleResize);
this._pageChanging = false;
// check we have the right tint applied for this theme. // 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 // 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 // racing with the theme CSS download finishing from index.js
@ -363,13 +370,58 @@ export default React.createClass({
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
}, },
componentDidUpdate: function() { componentWillUpdate: function(props, state) {
if (this.shouldTrackPageChange(this.state, state)) {
this.startPageChangeTimer();
}
},
componentDidUpdate: function(prevProps, prevState) {
if (this.shouldTrackPageChange(prevState, this.state)) {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
}
if (this.focusComposer) { if (this.focusComposer) {
dis.dispatch({action: 'focus_composer'}); dis.dispatch({action: 'focus_composer'});
this.focusComposer = false; this.focusComposer = false;
} }
}, },
startPageChangeTimer() {
// This shouldn't happen because componentWillUpdate and componentDidUpdate
// are used.
if (this._pageChanging) {
console.warn('MatrixChat.startPageChangeTimer: timer already started');
return;
}
this._pageChanging = true;
performance.mark('riot_MatrixChat_page_change_start');
},
stopPageChangeTimer() {
if (!this._pageChanging) {
console.warn('MatrixChat.stopPageChangeTimer: timer not started');
return;
}
this._pageChanging = false;
performance.mark('riot_MatrixChat_page_change_stop');
performance.measure(
'riot_MatrixChat_page_change_delta',
'riot_MatrixChat_page_change_start',
'riot_MatrixChat_page_change_stop',
);
performance.clearMarks('riot_MatrixChat_page_change_start');
performance.clearMarks('riot_MatrixChat_page_change_stop');
const measurement = performance.getEntriesByName('riot_MatrixChat_page_change_delta').pop();
return measurement.duration;
},
shouldTrackPageChange(prevState, state) {
return prevState.currentRoomId !== state.currentRoomId ||
prevState.view !== state.view ||
prevState.page_type !== state.page_type;
},
setStateForNewView: function(state) { setStateForNewView: function(state) {
if (state.view === undefined) { if (state.view === undefined) {
throw new Error("setStateForNewView with no view!"); throw new Error("setStateForNewView with no view!");
@ -607,6 +659,16 @@ export default React.createClass({
case 'send_event': case 'send_event':
this.onSendEvent(payload.room_id, payload.event); this.onSendEvent(payload.room_id, payload.event);
break; break;
case 'aria_hide_main_app':
this.setState({
hideToSRUsers: true,
});
break;
case 'aria_unhide_main_app':
this.setState({
hideToSRUsers: false,
});
break;
} }
}, },
@ -617,18 +679,26 @@ export default React.createClass({
}, },
_startRegistration: function(params) { _startRegistration: function(params) {
this.setStateForNewView({ const newState = {
view: VIEWS.REGISTER, view: VIEWS.REGISTER,
// these params may be undefined, but if they are, };
// unset them from our state: we don't want to
// resume a previous registration session if the // Only honour params if they are all present, otherwise we reset
// user just clicked 'register' // HS and IS URLs when switching to registration.
register_client_secret: params.client_secret, if (params.client_secret &&
register_session_id: params.session_id, params.session_id &&
register_hs_url: params.hs_url, params.hs_url &&
register_is_url: params.is_url, params.is_url &&
register_id_sid: params.sid, params.sid
}); ) {
newState.register_client_secret = params.client_secret;
newState.register_session_id = params.session_id;
newState.register_hs_url = params.hs_url;
newState.register_is_url = params.is_url;
newState.register_id_sid = params.sid;
}
this.setStateForNewView(newState);
this.notifyNewScreen('register'); this.notifyNewScreen('register');
}, },
@ -846,16 +916,36 @@ export default React.createClass({
}).close; }).close;
}, },
_leaveRoomWarnings: function(roomId) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
// Show a warning if there are additional complications.
const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', '');
const warnings = [];
if (joinRules) {
const rule = joinRules.getContent().join_rule;
if (rule !== "public") {
warnings.push((
<span className="warning" key="non_public_warning">
{ _t("This room is not public. You will not be able to rejoin without an invite.") }
</span>
));
}
}
return warnings;
},
_leaveRoom: function(roomId) { _leaveRoom: function(roomId) {
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this._leaveRoomWarnings(roomId);
Modal.createTrackedDialog('Leave room', '', QuestionDialog, { Modal.createTrackedDialog('Leave room', '', QuestionDialog, {
title: _t("Leave room"), title: _t("Leave room"),
description: ( description: (
<span> <span>
{ _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) }
{ warnings }
</span> </span>
), ),
onFinished: (shouldLeave) => { onFinished: (shouldLeave) => {
@ -1065,10 +1155,10 @@ export default 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._loggedInView) { if (!self._loggedInView || !self._loggedInView.child) {
return true; return true;
} }
return self._loggedInView.getDecoratedComponentInstance().canResetTimelineInRoom(roomId); return self._loggedInView.child.canResetTimelineInRoom(roomId);
}); });
cli.on('sync', function(state, prevState) { cli.on('sync', function(state, prevState) {
@ -1142,18 +1232,6 @@ export default React.createClass({
cli.on("crypto.warning", (type) => { cli.on("crypto.warning", (type) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
switch (type) { 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': case 'CRYPTO_WARNING_OLD_VERSION_DETECTED':
Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, { Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, {
title: _t('Old cryptography data detected'), title: _t('Old cryptography data detected'),
@ -1310,7 +1388,6 @@ export default React.createClass({
if (this.props.onNewScreen) { if (this.props.onNewScreen) {
this.props.onNewScreen(screen); this.props.onNewScreen(screen);
} }
Analytics.trackPageChange();
}, },
onAliasClick: function(event, alias) { onAliasClick: function(event, alias) {
@ -1480,6 +1557,17 @@ export default React.createClass({
} }
}, },
onServerConfigChange(config) {
const newState = {};
if (config.hsUrl) {
newState.register_hs_url = config.hsUrl;
}
if (config.isUrl) {
newState.register_is_url = config.isUrl;
}
this.setState(newState);
},
_makeRegistrationUrl: function(params) { _makeRegistrationUrl: function(params) {
if (this.props.startingFragmentQueryParams.referrer) { if (this.props.startingFragmentQueryParams.referrer) {
params.referrer = this.props.startingFragmentQueryParams.referrer; params.referrer = this.props.startingFragmentQueryParams.referrer;
@ -1568,6 +1656,7 @@ export default React.createClass({
onLoginClick={this.onLoginClick} onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null} onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
/> />
); );
} }
@ -1602,6 +1691,7 @@ export default React.createClass({
onForgotPasswordClick={this.onForgotPasswordClick} onForgotPasswordClick={this.onForgotPasswordClick}
enableGuest={this.props.enableGuest} enableGuest={this.props.enableGuest}
onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null} onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null}
onServerConfigChange={this.onServerConfigChange}
/> />
); );
} }

View file

@ -16,15 +16,15 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import shouldHideEvent from '../../shouldHideEvent'; import shouldHideEvent from '../../shouldHideEvent';
import {wantsDateSeparator} from '../../DateUtils';
import dis from "../../dispatcher"; import dis from "../../dispatcher";
import sdk from '../../index'; import sdk from '../../index';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
const MILLIS_IN_DAY = 86400000;
/* (almost) stateless UI component which builds the event tiles in the room timeline. /* (almost) stateless UI component which builds the event tiles in the room timeline.
*/ */
module.exports = React.createClass({ module.exports = React.createClass({
@ -32,63 +32,63 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// true to give the component a 'display: none' style. // true to give the component a 'display: none' style.
hidden: React.PropTypes.bool, hidden: PropTypes.bool,
// true to show a spinner at the top of the timeline to indicate // true to show a spinner at the top of the timeline to indicate
// back-pagination in progress // back-pagination in progress
backPaginating: React.PropTypes.bool, backPaginating: PropTypes.bool,
// true to show a spinner at the end of the timeline to indicate // true to show a spinner at the end of the timeline to indicate
// forward-pagination in progress // forward-pagination in progress
forwardPaginating: React.PropTypes.bool, forwardPaginating: PropTypes.bool,
// the list of MatrixEvents to display // the list of MatrixEvents to display
events: React.PropTypes.array.isRequired, events: PropTypes.array.isRequired,
// ID of an event to highlight. If undefined, no event will be highlighted. // ID of an event to highlight. If undefined, no event will be highlighted.
highlightedEventId: React.PropTypes.string, highlightedEventId: PropTypes.string,
// Should we show URL Previews // Should we show URL Previews
showUrlPreview: React.PropTypes.bool, showUrlPreview: PropTypes.bool,
// event after which we should show a read marker // event after which we should show a read marker
readMarkerEventId: React.PropTypes.string, readMarkerEventId: PropTypes.string,
// whether the read marker should be visible // whether the read marker should be visible
readMarkerVisible: React.PropTypes.bool, readMarkerVisible: PropTypes.bool,
// the userid of our user. This is used to suppress the read marker // the userid of our user. This is used to suppress the read marker
// for pending messages. // for pending messages.
ourUserId: React.PropTypes.string, ourUserId: PropTypes.string,
// true to suppress the date at the start of the timeline // true to suppress the date at the start of the timeline
suppressFirstDateSeparator: React.PropTypes.bool, suppressFirstDateSeparator: PropTypes.bool,
// whether to show read receipts // whether to show read receipts
showReadReceipts: React.PropTypes.bool, showReadReceipts: PropTypes.bool,
// true if updates to the event list should cause the scroll panel to // true if updates to the event list should cause the scroll panel to
// scroll down when we are at the bottom of the window. See ScrollPanel // scroll down when we are at the bottom of the window. See ScrollPanel
// for more details. // for more details.
stickyBottom: React.PropTypes.bool, stickyBottom: PropTypes.bool,
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func, onScroll: PropTypes.func,
// callback which is called when more content is needed. // callback which is called when more content is needed.
onFillRequest: React.PropTypes.func, onFillRequest: PropTypes.func,
// className for the panel // className for the panel
className: React.PropTypes.string.isRequired, className: PropTypes.string.isRequired,
// shape parameter to be passed to EventTiles // shape parameter to be passed to EventTiles
tileShape: React.PropTypes.string, tileShape: PropTypes.string,
// show twelve hour timestamps // show twelve hour timestamps
isTwelveHour: React.PropTypes.bool, isTwelveHour: PropTypes.bool,
// show timestamps always // show timestamps always
alwaysShowTimestamps: React.PropTypes.bool, alwaysShowTimestamps: PropTypes.bool,
}, },
componentWillMount: function() { componentWillMount: function() {
@ -325,7 +325,7 @@ module.exports = React.createClass({
const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial"); const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial");
if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) { if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
const dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1} showTwelveHour={this.props.isTwelveHour} /></li>; const dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1} /></li>;
ret.push(dateSeparator); ret.push(dateSeparator);
} }
@ -447,10 +447,18 @@ module.exports = React.createClass({
// is this a continuation of the previous message? // is this a continuation of the previous message?
let continuation = false; let continuation = false;
// Some events should appear as continuations from previous events of
// different types.
const continuedTypes = ['m.sticker', 'm.room.message'];
const eventTypeContinues =
prevEvent !== null &&
continuedTypes.includes(mxEv.getType()) &&
continuedTypes.includes(prevEvent.getType());
if (prevEvent !== null if (prevEvent !== null
&& prevEvent.sender && mxEv.sender && prevEvent.sender && mxEv.sender
&& mxEv.sender.userId === prevEvent.sender.userId && mxEv.sender.userId === prevEvent.sender.userId
&& mxEv.getType() == prevEvent.getType()) { && (mxEv.getType() == prevEvent.getType() || eventTypeContinues)) {
continuation = true; continuation = true;
} }
@ -479,7 +487,7 @@ module.exports = React.createClass({
// do we need a date separator since the last event? // do we need a date separator since the last event?
if (this._wantsDateSeparator(prevEvent, eventDate)) { if (this._wantsDateSeparator(prevEvent, eventDate)) {
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} showTwelveHour={this.props.isTwelveHour} /></li>; const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
ret.push(dateSeparator); ret.push(dateSeparator);
continuation = false; continuation = false;
} }
@ -522,17 +530,7 @@ module.exports = React.createClass({
// here. // here.
return !this.props.suppressFirstDateSeparator; return !this.props.suppressFirstDateSeparator;
} }
const prevEventDate = prevEvent.getDate(); return wantsDateSeparator(prevEvent.getDate(), nextEventDate);
if (!nextEventDate || !prevEventDate) {
return false;
}
// Return early for events that are > 24h apart
if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
return true;
}
// Compare weekdays
return prevEventDate.getDay() !== nextEventDate.getDay();
}, },
// get a list of read receipts that should be shown next to this event // get a list of read receipts that should be shown next to this event

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import GeminiScrollbar from 'react-gemini-scrollbar'; import PropTypes from 'prop-types';
import sdk from '../../index'; import sdk from '../../index';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
@ -26,7 +26,7 @@ export default withMatrixClient(React.createClass({
displayName: 'MyGroups', displayName: 'MyGroups',
propTypes: { propTypes: {
matrixClient: React.PropTypes.object.isRequired, matrixClient: PropTypes.object.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -62,6 +62,8 @@ export default withMatrixClient(React.createClass({
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"); const GroupTile = sdk.getComponent("groups.GroupTile");
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
let content; let content;
let contentHeader; let contentHeader;
@ -72,9 +74,26 @@ export default withMatrixClient(React.createClass({
}); });
contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />; contentHeader = groupNodes.length > 0 ? <h3>{ _t('Your Communities') }</h3> : <div />;
content = groupNodes.length > 0 ? content = groupNodes.length > 0 ?
<GeminiScrollbar className="mx_MyGroups_joinedGroups"> <GeminiScrollbarWrapper>
<div className="mx_MyGroups_microcopy">
<p>
{ _t(
"Did you know: you can use communities to filter your Riot.im experience!",
) }
</p>
<p>
{ _t(
"To set up a filter, drag a community avatar over to the filter panel on " +
"the far left hand side of the screen. You can click on an avatar in the " +
"filter panel at any time to see only the rooms and people associated " +
"with that community.",
) }
</p>
</div>
<div className="mx_MyGroups_joinedGroups">
{ groupNodes } { groupNodes }
</GeminiScrollbar> : </div>
</GeminiScrollbarWrapper> :
<div className="mx_MyGroups_placeholder"> <div className="mx_MyGroups_placeholder">
{ _t( { _t(
"You're not currently a member of any communities.", "You're not currently a member of any communities.",

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import sdk from '../../index'; import sdk from '../../index';
@ -23,7 +24,7 @@ import WhoIsTyping from '../../WhoIsTyping';
import MatrixClientPeg from '../../MatrixClientPeg'; import MatrixClientPeg from '../../MatrixClientPeg';
import MemberAvatar from '../views/avatars/MemberAvatar'; import MemberAvatar from '../views/avatars/MemberAvatar';
import Resend from '../../Resend'; import Resend from '../../Resend';
import { showUnknownDeviceDialogForMessages } from '../../cryptodevices'; import * as cryptodevices from '../../cryptodevices';
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
@ -41,59 +42,59 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// the room this statusbar is representing. // the room this statusbar is representing.
room: React.PropTypes.object.isRequired, room: PropTypes.object.isRequired,
// 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: 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: PropTypes.bool,
// This is true when the user is alone in the room, but has also sent a message. // 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 // Used to suggest to the user to invite someone
sentMessageAndIsAlone: React.PropTypes.bool, sentMessageAndIsAlone: 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)
hasActiveCall: React.PropTypes.bool, hasActiveCall: PropTypes.bool,
// Number of names to display in typing indication. E.g. set to 3, will // Number of names to display in typing indication. E.g. set to 3, will
// result in "X, Y, Z and 100 others are typing." // result in "X, Y, Z and 100 others are typing."
whoIsTypingLimit: React.PropTypes.number, whoIsTypingLimit: PropTypes.number,
// callback for when the user clicks on the 'resend all' button in the // callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar // 'unsent messages' bar
onResendAllClick: React.PropTypes.func, onResendAllClick: PropTypes.func,
// callback for when the user clicks on the 'cancel all' button in the // callback for when the user clicks on the 'cancel all' button in the
// 'unsent messages' bar // 'unsent messages' bar
onCancelAllClick: React.PropTypes.func, onCancelAllClick: PropTypes.func,
// callback for when the user clicks on the 'invite others' button in the // callback for when the user clicks on the 'invite others' button in the
// 'you are alone' bar // 'you are alone' bar
onInviteClick: React.PropTypes.func, onInviteClick: PropTypes.func,
// callback for when the user clicks on the 'stop warning me' button in the // callback for when the user clicks on the 'stop warning me' button in the
// 'you are alone' bar // 'you are alone' bar
onStopWarningClick: React.PropTypes.func, onStopWarningClick: 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: PropTypes.func,
// callback for when we do something that changes the size of the // callback for when we do something that changes the size of the
// status bar. This is used to trigger a re-layout in the parent // status bar. This is used to trigger a re-layout in the parent
// component. // component.
onResize: React.PropTypes.func, onResize: PropTypes.func,
// callback for when the status bar can be hidden from view, as it is // callback for when the status bar can be hidden from view, as it is
// not displaying anything // not displaying anything
onHidden: React.PropTypes.func, onHidden: PropTypes.func,
// callback for when the status bar is displaying something and should // callback for when the status bar is displaying something and should
// be visible // be visible
onVisible: React.PropTypes.func, onVisible: PropTypes.func,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -147,6 +148,13 @@ module.exports = React.createClass({
}); });
}, },
_onSendWithoutVerifyingClick: function() {
cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => {
cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices);
Resend.resendUnsentEvents(this.props.room);
});
},
_onResendAllClick: function() { _onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room); Resend.resendUnsentEvents(this.props.room);
}, },
@ -156,7 +164,7 @@ module.exports = React.createClass({
}, },
_onShowDevicesClick: function() { _onShowDevicesClick: function() {
showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room); cryptodevices.showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room);
}, },
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) { _onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
@ -169,8 +177,10 @@ module.exports = React.createClass({
// 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._getSize()) {
this.props.onVisible(); if (this.props.onVisible) this.props.onVisible();
} else {
if (this.props.onHidden) this.props.onHidden();
} }
}, },
@ -286,10 +296,11 @@ module.exports = React.createClass({
if (hasUDE) { if (hasUDE) {
title = _t("Message not sent due to unknown devices being present"); title = _t("Message not sent due to unknown devices being present");
content = _t( content = _t(
"<showDevicesText>Show devices</showDevicesText> or <cancelText>cancel all</cancelText>.", "<showDevicesText>Show devices</showDevicesText>, <sendAnywayText>send anyway</sendAnywayText> or <cancelText>cancel</cancelText>.",
{}, {},
{ {
'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>, 'showDevicesText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onShowDevicesClick}>{ sub }</a>,
'sendAnywayText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="sendAnyway" onClick={this._onSendWithoutVerifyingClick}>{ sub }</a>,
'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>, 'cancelText': (sub) => <a className="mx_RoomStatusBar_resend_link" key="cancel" onClick={this._onCancelAllClick}>{ sub }</a>,
}, },
); );
@ -302,11 +313,11 @@ module.exports = React.createClass({
) { ) {
title = unsentMessages[0].error.data.error; title = unsentMessages[0].error.data.error;
} else { } else {
title = _t("Some of your messages have not been sent."); title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length });
} }
content = _t("<resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " + content = _t("%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. " +
"You can also select individual messages to resend or cancel.", "You can also select individual messages to resend or cancel.",
{}, { count: unsentMessages.length },
{ {
'resendText': (sub) => 'resendText': (sub) =>
<a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>, <a className="mx_RoomStatusBar_resend_link" key="resend" onClick={this._onResendAllClick}>{ sub }</a>,

View file

@ -24,6 +24,7 @@ import shouldHideEvent from "../../shouldHideEvent";
const React = require("react"); const React = require("react");
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
import Promise from 'bluebird'; import Promise from 'bluebird';
const classNames = require("classnames"); const classNames = require("classnames");
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
@ -58,18 +59,18 @@ if (DEBUG) {
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomView', displayName: 'RoomView',
propTypes: { propTypes: {
ConferenceHandler: React.PropTypes.any, ConferenceHandler: PropTypes.any,
// Called with the credentials of a registered user (if they were a ROU that // Called with the credentials of a registered user (if they were a ROU that
// transitioned to PWLU) // transitioned to PWLU)
onRegistered: React.PropTypes.func, onRegistered: PropTypes.func,
// An object representing a third party invite to join this room // An object representing a third party invite to join this room
// Fields: // Fields:
// * inviteSignUrl (string) The URL used to join this room from an email invite // * inviteSignUrl (string) The URL used to join this room from an email invite
// (given as part of the link in the invite email) // (given as part of the link in the invite email)
// * invitedEmail (string) The email address that was invited to this room // * invitedEmail (string) The email address that was invited to this room
thirdPartyInvite: React.PropTypes.object, thirdPartyInvite: PropTypes.object,
// Any data about the room that would normally come from the Home Server // Any data about the room that would normally come from the Home Server
// but has been passed out-of-band, eg. the room name and avatar URL // but has been passed out-of-band, eg. the room name and avatar URL
@ -80,10 +81,10 @@ module.exports = React.createClass({
// * avatarUrl (string) The mxc:// avatar URL for the room // * avatarUrl (string) The mxc:// avatar URL for the room
// * inviterName (string) The display name of the person who // * inviterName (string) The display name of the person who
// * invited us tovthe room // * invited us tovthe room
oobData: React.PropTypes.object, oobData: PropTypes.object,
// is the RightPanel collapsed? // is the RightPanel collapsed?
collapsedRhs: React.PropTypes.bool, collapsedRhs: PropTypes.bool,
}, },
getInitialState: function() { getInitialState: function() {
@ -263,12 +264,19 @@ module.exports = React.createClass({
isPeeking: true, // this will change to false if peeking fails isPeeking: true, // this will change to false if peeking fails
}); });
MatrixClientPeg.get().peekInRoom(roomId).then((room) => { MatrixClientPeg.get().peekInRoom(roomId).then((room) => {
if (this.unmounted) {
return;
}
this.setState({ this.setState({
room: room, room: room,
peekLoading: false, peekLoading: false,
}); });
this._onRoomLoaded(room); this._onRoomLoaded(room);
}, (err) => { }, (err) => {
if (this.unmounted) {
return;
}
// Stop peeking if anything went wrong // Stop peeking if anything went wrong
this.setState({ this.setState({
isPeeking: false, isPeeking: false,
@ -285,7 +293,7 @@ module.exports = React.createClass({
} else { } else {
throw err; throw err;
} }
}).done(); });
} }
} else if (room) { } else if (room) {
// Stop peeking because we have joined this room previously // Stop peeking because we have joined this room previously
@ -459,6 +467,15 @@ module.exports = React.createClass({
case 'message_sent': case 'message_sent':
this._checkIfAlone(this.state.room); this._checkIfAlone(this.state.room);
break; break;
case 'post_sticker_message':
this.injectSticker(
payload.data.content.url,
payload.data.content.info,
payload.data.description || payload.data.name);
break;
case 'picture_snapshot':
this.uploadFile(payload.file);
break;
case 'notifier_enabled': case 'notifier_enabled':
case 'upload_failed': case 'upload_failed':
case 'upload_started': case 'upload_started':
@ -619,8 +636,8 @@ module.exports = React.createClass({
const room = this.state.room; const room = this.state.room;
if (!room) return; if (!room) return;
const color_scheme = SettingsStore.getValue("roomColor", room.room_id);
console.log("Tinter.tint from updateTint"); console.log("Tinter.tint from updateTint");
const color_scheme = SettingsStore.getValue("roomColor", room.roomId);
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}, },
@ -669,23 +686,7 @@ module.exports = React.createClass({
// a member state changed in this room // a member state changed in this room
// refresh the conf call notification state // refresh the conf call notification state
this._updateConfCallNotification(); this._updateConfCallNotification();
this._updateDMState();
// 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
// into.
const me = MatrixClientPeg.get().credentials.userId;
if (this.state.joining && this.state.room.hasMembershipState(me, "join")) {
// Having just joined a room, check to see if it looks like a DM room, and if so,
// mark it as one. This is to work around the fact that some clients don't support
// is_direct. We should remove this once they do.
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
if (Rooms.looksLikeDirectMessageRoom(this.state.room, me)) {
// XXX: There's not a whole lot we can really do if this fails: at best
// perhaps we could try a couple more times, but since it's a temporary
// compatability workaround, let's not bother.
Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender()).done();
}
}
}, 500), }, 500),
_checkIfAlone: function(room) { _checkIfAlone: function(room) {
@ -726,6 +727,44 @@ module.exports = React.createClass({
}); });
}, },
_updateDMState() {
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
if (!me || me.membership !== "join") {
return;
}
// The user may have accepted an invite with is_direct set
if (me.events.member.getPrevContent().membership === "invite" &&
me.events.member.getPrevContent().is_direct
) {
// This is a DM with the sender of the invite event (which we assume
// preceded the join event)
Rooms.setDMRoom(
this.state.room.roomId,
me.events.member.getUnsigned().prev_sender,
);
return;
}
const invitedMembers = this.state.room.getMembersWithMembership("invite");
const joinedMembers = this.state.room.getMembersWithMembership("join");
// There must be one invited member and one joined member
if (invitedMembers.length !== 1 || joinedMembers.length !== 1) {
return;
}
// The user may have sent an invite with is_direct sent
const other = invitedMembers[0];
if (other &&
other.membership === "invite" &&
other.events.member.getContent().is_direct
) {
Rooms.setDMRoom(this.state.room.roomId, other.userId);
return;
}
},
onSearchResultsResize: function() { onSearchResultsResize: function() {
dis.dispatch({ action: 'timeline_resize' }, true); dis.dispatch({ action: 'timeline_resize' }, true);
}, },
@ -818,18 +857,6 @@ module.exports = React.createClass({
action: 'join_room', action: 'join_room',
opts: { inviteSignUrl: signUrl }, opts: { inviteSignUrl: signUrl },
}); });
// if this is an invite and has the 'direct' hint set, mark it as a DM room now.
if (this.state.room) {
const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId);
if (me && me.membership == 'invite') {
if (me.events.member.getContent().is_direct) {
// The 'direct' hint is there, so declare that this is a DM room for
// whoever invited us.
return Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender());
}
}
}
return Promise.resolve(); return Promise.resolve();
}); });
}, },
@ -889,7 +916,7 @@ module.exports = React.createClass({
ContentMessages.sendContentToRoom( ContentMessages.sendContentToRoom(
file, this.state.room.roomId, MatrixClientPeg.get(), file, this.state.room.roomId, MatrixClientPeg.get(),
).done(undefined, (error) => { ).catch((error) => {
if (error.name === "UnknownDeviceError") { if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this // Let the staus bar handle this
return; return;
@ -898,11 +925,27 @@ module.exports = React.createClass({
console.error("Failed to upload file " + file + " " + error); console.error("Failed to upload file " + file + " " + error);
Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, { Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, {
title: _t('Failed to upload file'), title: _t('Failed to upload file'),
description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), description: ((error && error.message)
? error.message : _t("Server may be unavailable, overloaded, or the file too big")),
}); });
}); });
}, },
injectSticker: function(url, info, text) {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'view_set_mxid'});
return;
}
ContentMessages.sendStickerContentToRoom(url, this.state.room.roomId, info, text, MatrixClientPeg.get())
.done(undefined, (error) => {
if (error.name === "UnknownDeviceError") {
// Let the staus bar handle this
return;
}
});
},
onSearch: function(term, scope) { onSearch: function(term, scope) {
this.setState({ this.setState({
searchTerm: term, searchTerm: term,
@ -1347,10 +1390,12 @@ module.exports = React.createClass({
}, },
onStatusBarHidden: function() { onStatusBarHidden: function() {
if (this.unmounted) return; // This is currently not desired as it is annoying if it keeps expanding and collapsing
// TODO: Find a less annoying way of hiding the status bar
/*if (this.unmounted) return;
this.setState({ this.setState({
statusBarVisible: false, statusBarVisible: false,
}); });*/
}, },
showSettings: function(show) { showSettings: function(show) {
@ -1583,7 +1628,8 @@ module.exports = React.createClass({
displayConfCallNotification={this.state.displayConfCallNotification} displayConfCallNotification={this.state.displayConfCallNotification}
maxHeight={this.state.auxPanelMaxHeight} maxHeight={this.state.auxPanelMaxHeight}
onResize={this.onChildResize} onResize={this.onChildResize}
showApps={this.state.showApps && !this.state.editingRoomSettings} > showApps={this.state.showApps}
hideAppsDrawer={this.state.editingRoomSettings} >
{ aux } { aux }
</AuxPanel> </AuxPanel>
); );

View file

@ -16,9 +16,10 @@ limitations under the License.
const React = require("react"); const React = require("react");
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
const GeminiScrollbar = require('react-gemini-scrollbar'); import PropTypes from 'prop-types';
import Promise from 'bluebird'; import Promise from 'bluebird';
import { KeyCode } from '../../Keyboard'; import { KeyCode } from '../../Keyboard';
import sdk from '../../index.js';
const DEBUG_SCROLL = false; const DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true; // var DEBUG_SCROLL = true;
@ -86,7 +87,7 @@ module.exports = React.createClass({
* scroll down to show the new element, rather than preserving the * scroll down to show the new element, rather than preserving the
* existing view. * existing view.
*/ */
stickyBottom: React.PropTypes.bool, stickyBottom: PropTypes.bool,
/* startAtBottom: if set to true, the view is assumed to start /* startAtBottom: if set to true, the view is assumed to start
* scrolled to the bottom. * scrolled to the bottom.
@ -95,7 +96,7 @@ module.exports = React.createClass({
* behaviour stays the same for other uses of ScrollPanel. * behaviour stays the same for other uses of ScrollPanel.
* If so, let's remove this parameter down the line. * If so, let's remove this parameter down the line.
*/ */
startAtBottom: React.PropTypes.bool, startAtBottom: PropTypes.bool,
/* onFillRequest(backwards): a callback which is called on scroll when /* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards = * the user nears the start (backwards = true) or end (backwards =
@ -110,7 +111,7 @@ module.exports = React.createClass({
* directon (at this time) - which will stop the pagination cycle until * directon (at this time) - which will stop the pagination cycle until
* the user scrolls again. * the user scrolls again.
*/ */
onFillRequest: React.PropTypes.func, onFillRequest: PropTypes.func,
/* onUnfillRequest(backwards): a callback which is called on scroll when /* onUnfillRequest(backwards): a callback which is called on scroll when
* there are children elements that are far out of view and could be removed * there are children elements that are far out of view and could be removed
@ -121,24 +122,24 @@ module.exports = React.createClass({
* first element to remove if removing from the front/bottom, and last element * first element to remove if removing from the front/bottom, and last element
* to remove if removing from the back/top. * to remove if removing from the back/top.
*/ */
onUnfillRequest: React.PropTypes.func, onUnfillRequest: PropTypes.func,
/* onScroll: a callback which is called whenever any scroll happens. /* onScroll: a callback which is called whenever any scroll happens.
*/ */
onScroll: React.PropTypes.func, onScroll: PropTypes.func,
/* onResize: a callback which is called whenever the Gemini scroll /* onResize: a callback which is called whenever the Gemini scroll
* panel is resized * panel is resized
*/ */
onResize: React.PropTypes.func, onResize: PropTypes.func,
/* className: classnames to add to the top-level div /* className: classnames to add to the top-level div
*/ */
className: React.PropTypes.string, className: PropTypes.string,
/* style: styles to add to the top-level div /* style: styles to add to the top-level div
*/ */
style: React.PropTypes.object, style: PropTypes.object,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -223,7 +224,7 @@ module.exports = React.createClass({
onResize: function() { onResize: function() {
this.props.onResize(); this.props.onResize();
this.checkScroll(); this.checkScroll();
this.refs.geminiPanel.forceUpdate(); if (this._gemScroll) this._gemScroll.forceUpdate();
}, },
// after an update to the contents of the panel, check that the scroll is // after an update to the contents of the panel, check that the scroll is
@ -664,14 +665,25 @@ module.exports = React.createClass({
throw new Error("ScrollPanel._getScrollNode called when unmounted"); throw new Error("ScrollPanel._getScrollNode called when unmounted");
} }
return this.refs.geminiPanel.scrollbar.getViewElement(); if (!this._gemScroll) {
// Likewise, we should have the ref by this point, but if not
// turn the NPE into something meaningful.
throw new Error("ScrollPanel._getScrollNode called before gemini ref collected");
}
return this._gemScroll.scrollbar.getViewElement();
},
_collectGeminiScroll: function(gemScroll) {
this._gemScroll = gemScroll;
}, },
render: function() { render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// TODO: the classnames on the div and ol could do with being updated to // TODO: the classnames on the div and ol could do with being updated to
// reflect the fact that we don't necessarily contain a list of messages. // reflect the fact that we don't necessarily contain a list of messages.
// it's not obvious why we have a separate div and ol anyway. // it's not obvious why we have a separate div and ol anyway.
return (<GeminiScrollbar autoshow={true} ref="geminiPanel" return (<GeminiScrollbarWrapper autoshow={true} wrappedRef={this._collectGeminiScroll}
onScroll={this.onScroll} onResize={this.onResize} onScroll={this.onScroll} onResize={this.onResize}
className={this.props.className} style={this.props.style}> className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper"> <div className="mx_RoomView_messageListWrapper">
@ -679,7 +691,7 @@ module.exports = React.createClass({
{ this.props.children } { this.props.children }
</ol> </ol>
</div> </div>
</GeminiScrollbar> </GeminiScrollbarWrapper>
); );
}, },
}); });

View file

@ -17,15 +17,15 @@ 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 { MatrixClient } from 'matrix-js-sdk';
import FilterStore from '../../stores/FilterStore';
import FlairStore from '../../stores/FlairStore';
import TagOrderStore from '../../stores/TagOrderStore'; import TagOrderStore from '../../stores/TagOrderStore';
import GroupActions from '../../actions/GroupActions'; import GroupActions from '../../actions/GroupActions';
import TagOrderActions from '../../actions/TagOrderActions';
import sdk from '../../index'; import sdk from '../../index';
import dis from '../../dispatcher'; import dis from '../../dispatcher';
import { _t } from '../../languageHandler';
import { Droppable } from 'react-beautiful-dnd';
const TagPanel = React.createClass({ const TagPanel = React.createClass({
displayName: 'TagPanel', displayName: 'TagPanel',
@ -36,17 +36,7 @@ const TagPanel = React.createClass({
getInitialState() { getInitialState() {
return { return {
// A list of group profiles for tags that are group IDs. The intention in future orderedTags: [],
// is to allow arbitrary tags to be selected in the TagPanel, not just groups.
// For now, it suffices to maintain a list of ordered group profiles.
orderedGroupTagProfiles: [
// {
// groupId: '+awesome:foo.bar',{
// name: 'My Awesome Community',
// avatarUrl: 'mxc://...',
// shortDescription: 'Some description...',
// },
],
selectedTags: [], selectedTags: [],
}; };
}, },
@ -54,28 +44,15 @@ const TagPanel = React.createClass({
componentWillMount: function() { componentWillMount: function() {
this.unmounted = false; this.unmounted = false;
this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership); this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.on("sync", this._onClientSync);
this._filterStoreToken = FilterStore.addListener(() => {
if (this.unmounted) {
return;
}
this.setState({
selectedTags: FilterStore.getSelectedTags(),
});
});
this._tagOrderStoreToken = TagOrderStore.addListener(() => { this._tagOrderStoreToken = TagOrderStore.addListener(() => {
if (this.unmounted) { if (this.unmounted) {
return; return;
} }
this.setState({
const orderedTags = TagOrderStore.getOrderedTags() || []; orderedTags: TagOrderStore.getOrderedTags() || [],
const orderedGroupTags = orderedTags.filter((t) => t[0] === '+'); selectedTags: TagOrderStore.getSelectedTags(),
// XXX: One profile lookup failing will bring the whole lot down
Promise.all(orderedGroupTags.map(
(groupId) => FlairStore.getGroupProfileCached(this.context.matrixClient, groupId),
)).then((orderedGroupTagProfiles) => {
if (this.unmounted) return;
this.setState({orderedGroupTagProfiles});
}); });
}); });
// This could be done by anything with a matrix client // This could be done by anything with a matrix client
@ -85,6 +62,7 @@ const TagPanel = React.createClass({
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership);
this.context.matrixClient.removeListener("sync", this._onClientSync);
if (this._filterStoreToken) { if (this._filterStoreToken) {
this._filterStoreToken.remove(); this._filterStoreToken.remove();
} }
@ -95,7 +73,17 @@ const TagPanel = React.createClass({
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
}, },
onClick() { _onClientSync(syncState, prevState) {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING or PREPARED.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected) {
// Load joined groups
dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient));
}
},
onMouseDown(e) {
dis.dispatch({action: 'deselect_tags'}); dis.dispatch({action: 'deselect_tags'});
}, },
@ -104,30 +92,65 @@ const TagPanel = React.createClass({
dis.dispatch({action: 'view_create_group'}); dis.dispatch({action: 'view_create_group'});
}, },
onTagTileEndDrag() { onClearFilterClick(ev) {
dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient)); dis.dispatch({action: 'deselect_tags'});
}, },
render() { render() {
const GroupsButton = sdk.getComponent('elements.GroupsButton');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
const TintableSvg = sdk.getComponent('elements.TintableSvg'); const TintableSvg = sdk.getComponent('elements.TintableSvg');
const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const tags = this.state.orderedGroupTagProfiles.map((groupProfile, index) => {
const tags = this.state.orderedTags.map((tag, index) => {
return <DNDTagTile return <DNDTagTile
key={groupProfile.groupId + '_' + index} key={tag}
groupProfile={groupProfile} tag={tag}
selected={this.state.selectedTags.includes(groupProfile.groupId)} index={index}
onEndDrag={this.onTagTileEndDrag} selected={this.state.selectedTags.includes(tag)}
/>; />;
}); });
return <div className="mx_TagPanel" onClick={this.onClick}>
<div className="mx_TagPanel_tagTileContainer"> const clearButton = this.state.selectedTags.length > 0 ?
{ tags } <TintableSvg src="img/icons-close.svg" width="24" height="24"
</div> alt={_t("Clear filter")}
<AccessibleButton className="mx_TagPanel_createGroupButton" onClick={this.onCreateGroupClick}> title={_t("Clear filter")}
<TintableSvg src="img/icons-create-room.svg" width="25" height="25" /> /> :
<div />;
return <div className="mx_TagPanel">
<AccessibleButton className="mx_TagPanel_clearButton" onClick={this.onClearFilterClick}>
{ clearButton }
</AccessibleButton> </AccessibleButton>
<div className="mx_TagPanel_divider" />
<GeminiScrollbarWrapper
className="mx_TagPanel_scroller"
autoshow={true}
// XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273
// instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6253
onMouseDown={this.onMouseDown}
>
<Droppable
droppableId="tag-panel-droppable"
type="draggable-TagTile"
>
{ (provided, snapshot) => (
<div
className="mx_TagPanel_tagTileContainer"
ref={provided.innerRef}
>
{ tags }
{ provided.placeholder }
</div>
) }
</Droppable>
</GeminiScrollbarWrapper>
<div className="mx_TagPanel_divider" />
<div className="mx_TagPanel_groupsButton">
<GroupsButton tooltip={true} />
</div>
</div>; </div>;
}, },
}); });

View file

@ -19,6 +19,7 @@ import SettingsStore from "../../settings/SettingsStore";
const React = require('react'); const React = require('react');
const ReactDOM = require("react-dom"); const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
import Promise from 'bluebird'; import Promise from 'bluebird';
const Matrix = require("matrix-js-sdk"); const Matrix = require("matrix-js-sdk");
@ -58,49 +59,49 @@ var TimelinePanel = React.createClass({
// representing. This may or may not have a room, depending on what it's // representing. This may or may not have a room, depending on what it's
// a timeline representing. If it has a room, we maintain RRs etc for // a timeline representing. If it has a room, we maintain RRs etc for
// that room. // that room.
timelineSet: React.PropTypes.object.isRequired, timelineSet: PropTypes.object.isRequired,
showReadReceipts: React.PropTypes.bool, showReadReceipts: PropTypes.bool,
// Enable managing RRs and RMs. These require the timelineSet to have a room. // Enable managing RRs and RMs. These require the timelineSet to have a room.
manageReadReceipts: React.PropTypes.bool, manageReadReceipts: PropTypes.bool,
manageReadMarkers: React.PropTypes.bool, manageReadMarkers: PropTypes.bool,
// true to give the component a 'display: none' style. // true to give the component a 'display: none' style.
hidden: React.PropTypes.bool, hidden: PropTypes.bool,
// ID of an event to highlight. If undefined, no event will be highlighted. // ID of an event to highlight. If undefined, no event will be highlighted.
// typically this will be either 'eventId' or undefined. // typically this will be either 'eventId' or undefined.
highlightedEventId: React.PropTypes.string, highlightedEventId: PropTypes.string,
// id of an event to jump to. If not given, will go to the end of the // id of an event to jump to. If not given, will go to the end of the
// live timeline. // live timeline.
eventId: React.PropTypes.string, eventId: PropTypes.string,
// where to position the event given by eventId, in pixels from the // where to position the event given by eventId, in pixels from the
// bottom of the viewport. If not given, will try to put the event // bottom of the viewport. If not given, will try to put the event
// half way down the viewport. // half way down the viewport.
eventPixelOffset: React.PropTypes.number, eventPixelOffset: PropTypes.number,
// Should we show URL Previews // Should we show URL Previews
showUrlPreview: React.PropTypes.bool, showUrlPreview: PropTypes.bool,
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func, onScroll: PropTypes.func,
// 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: PropTypes.func,
// maximum number of events to show in a timeline // maximum number of events to show in a timeline
timelineCap: React.PropTypes.number, timelineCap: PropTypes.number,
// classname to use for the messagepanel // classname to use for the messagepanel
className: React.PropTypes.string, className: PropTypes.string,
// shape property to be passed to EventTiles // shape property to be passed to EventTiles
tileShape: React.PropTypes.string, tileShape: PropTypes.string,
// placeholder text to use if the timeline is empty // placeholder text to use if the timeline is empty
empty: React.PropTypes.string, empty: PropTypes.string,
}, },
statics: { statics: {
@ -301,6 +302,8 @@ var TimelinePanel = React.createClass({
// set off a pagination request. // set off a pagination request.
onMessageListFillRequest: function(backwards) { onMessageListFillRequest: function(backwards) {
if (!this._shouldPaginate()) return Promise.resolve(false);
const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate'; const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate';
const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating'; const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating';
@ -621,6 +624,7 @@ var TimelinePanel = React.createClass({
this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0);
dis.dispatch({ dis.dispatch({
action: 'on_room_read', action: 'on_room_read',
roomId: this.props.timelineSet.room.roomId,
}); });
} }
} }
@ -1090,6 +1094,17 @@ var TimelinePanel = React.createClass({
}, this.props.onReadMarkerUpdated); }, this.props.onReadMarkerUpdated);
}, },
_shouldPaginate: function() {
// don't try to paginate while events in the timeline are
// still being decrypted. We don't render events while they're
// being decrypted, so they don't take up space in the timeline.
// This means we can pull quite a lot of events into the timeline
// and end up trying to render a lot of events.
return !this.state.events.some((e) => {
return e.isBeingDecrypted();
});
},
render: function() { render: function() {
const MessagePanel = sdk.getComponent("structures.MessagePanel"); const MessagePanel = sdk.getComponent("structures.MessagePanel");
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
@ -1107,7 +1122,7 @@ var TimelinePanel = React.createClass({
// exist. // exist.
if (this.state.timelineLoading) { if (this.state.timelineLoading) {
return ( return (
<div className={this.props.className + " mx_RoomView_messageListWrapper"}> <div className="mx_RoomView_messagePanelSpinner">
<Loader /> <Loader />
</div> </div>
); );

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
const React = require('react'); const React = require('react');
import PropTypes from 'prop-types';
const ContentMessages = require('../../ContentMessages'); const ContentMessages = require('../../ContentMessages');
const dis = require('../../dispatcher'); const dis = require('../../dispatcher');
const filesize = require('filesize'); const filesize = require('filesize');
@ -22,7 +23,7 @@ import { _t } from '../../languageHandler';
module.exports = React.createClass({displayName: 'UploadBar', module.exports = React.createClass({displayName: 'UploadBar',
propTypes: { propTypes: {
room: React.PropTypes.object, room: PropTypes.object,
}, },
componentDidMount: function() { componentDidMount: function() {

View file

@ -19,6 +19,7 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
const React = require('react'); const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
import PropTypes from 'prop-types';
const sdk = require('../../index'); const sdk = require('../../index');
const MatrixClientPeg = require("../../MatrixClientPeg"); const MatrixClientPeg = require("../../MatrixClientPeg");
const PlatformPeg = require("../../PlatformPeg"); const PlatformPeg = require("../../PlatformPeg");
@ -29,7 +30,6 @@ import Promise from 'bluebird';
const packageJson = require('../../../package.json'); const packageJson = require('../../../package.json');
const UserSettingsStore = require('../../UserSettingsStore'); const UserSettingsStore = require('../../UserSettingsStore');
const CallMediaHandler = require('../../CallMediaHandler'); const CallMediaHandler = require('../../CallMediaHandler');
const GeminiScrollbar = require('react-gemini-scrollbar');
const Email = require('../../email'); const Email = require('../../email');
const AddThreepid = require('../../AddThreepid'); const AddThreepid = require('../../AddThreepid');
const SdkConfig = require('../../SdkConfig'); const SdkConfig = require('../../SdkConfig');
@ -78,6 +78,7 @@ const SIMPLE_SETTINGS = [
{ id: "Pill.shouldHidePillAvatar" }, { id: "Pill.shouldHidePillAvatar" },
{ id: "TextualBody.disableBigEmoji" }, { id: "TextualBody.disableBigEmoji" },
{ id: "VideoView.flipVideoHorizontally" }, { id: "VideoView.flipVideoHorizontally" },
{ id: "TagPanel.disableTagPanel" },
]; ];
// These settings must be defined in SettingsStore // These settings must be defined in SettingsStore
@ -125,8 +126,8 @@ const THEMES = [
const IgnoredUser = React.createClass({ const IgnoredUser = React.createClass({
propTypes: { propTypes: {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
onUnignored: React.PropTypes.func.isRequired, onUnignored: PropTypes.func.isRequired,
}, },
_onUnignoreClick: function() { _onUnignoreClick: function() {
@ -155,16 +156,16 @@ module.exports = React.createClass({
displayName: 'UserSettings', displayName: 'UserSettings',
propTypes: { propTypes: {
onClose: React.PropTypes.func, onClose: PropTypes.func,
// The brand string given when creating email pushers // The brand string given when creating email pushers
brand: React.PropTypes.string, brand: PropTypes.string,
// 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: PropTypes.string,
// Team token for the referral link. If falsy, the referral section will // Team token for the referral link. If falsy, the referral section will
// not appear // not appear
teamToken: React.PropTypes.string, teamToken: PropTypes.string,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -375,7 +376,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: [
@ -793,11 +794,18 @@ module.exports = React.createClass({
} }
return ( return (
<div> <div>
<h3>{ _t("Bug Report") }</h3> <h3>{ _t("Debug Logs Submission") }</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
<p>{ _t("Found a bug?") }</p> <p>{
_t( "If you've submitted a bug via GitHub, debug logs can help " +
"us track down the problem. Debug logs contain application " +
"usage data including your username, the IDs or aliases of " +
"the rooms or groups you have visited and the usernames of " +
"other users. They do not contian messages.",
)
}</p>
<button className="mx_UserSettings_button danger" <button className="mx_UserSettings_button danger"
onClick={this._onBugReportClicked}>{ _t('Report it') } onClick={this._onBugReportClicked}>{ _t('Submit debug logs') }
</button> </button>
</div> </div>
</div> </div>
@ -811,6 +819,12 @@ 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.') }
<br />
{ _t('Privacy is important to us, so we don\'t collect any personal'
+ ' or identifiable data for our analytics.') }
<div className="mx_UserSettings_advanced_spoiler" onClick={Analytics.showDetailsModal}>
{ _t('Learn more about how we use analytics.') }
</div>
{ ANALYTICS_SETTINGS.map( this._renderDeviceSetting ) } { ANALYTICS_SETTINGS.map( this._renderDeviceSetting ) }
</div> </div>
</div>; </div>;
@ -1103,6 +1117,7 @@ module.exports = React.createClass({
const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
const Notifications = sdk.getComponent("settings.Notifications"); const Notifications = sdk.getComponent("settings.Notifications");
const EditableText = sdk.getComponent('elements.EditableText'); const EditableText = sdk.getComponent('elements.EditableText');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
const avatarUrl = ( const avatarUrl = (
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
@ -1198,7 +1213,8 @@ module.exports = React.createClass({
onCancelClick={this.props.onClose} onCancelClick={this.props.onClose}
/> />
<GeminiScrollbar className="mx_UserSettings_body" <GeminiScrollbarWrapper
className="mx_UserSettings_body"
autoshow={true}> autoshow={true}>
<h3>{ _t("Profile") }</h3> <h3>{ _t("Profile") }</h3>
@ -1312,7 +1328,7 @@ module.exports = React.createClass({
{ this._renderDeactivateAccount() } { this._renderDeactivateAccount() }
</GeminiScrollbar> </GeminiScrollbarWrapper>
</div> </div>
); );
}, },

View file

@ -18,10 +18,12 @@ limitations under the License.
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import MatrixClientPeg from "../../../MatrixClientPeg"; import MatrixClientPeg from "../../../MatrixClientPeg";
import SdkConfig from "../../../SdkConfig";
import PasswordReset from "../../../PasswordReset"; import PasswordReset from "../../../PasswordReset";
@ -29,13 +31,13 @@ module.exports = React.createClass({
displayName: 'ForgotPassword', displayName: 'ForgotPassword',
propTypes: { propTypes: {
defaultHsUrl: React.PropTypes.string, defaultHsUrl: PropTypes.string,
defaultIsUrl: React.PropTypes.string, defaultIsUrl: PropTypes.string,
customHsUrl: React.PropTypes.string, customHsUrl: PropTypes.string,
customIsUrl: React.PropTypes.string, customIsUrl: PropTypes.string,
onLoginClick: React.PropTypes.func, onLoginClick: PropTypes.func,
onRegisterClick: React.PropTypes.func, onRegisterClick: PropTypes.func,
onComplete: React.PropTypes.func.isRequired, onComplete: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -184,7 +186,7 @@ module.exports = React.createClass({
); );
} else { } else {
let serverConfigSection; let serverConfigSection;
if (!config.disable_custom_urls) { if (!SdkConfig.get().disable_custom_urls) {
serverConfigSection = ( serverConfigSection = (
<ServerConfig ref="serverConfig" <ServerConfig ref="serverConfig"
withToggleButton={true} withToggleButton={true}

View file

@ -18,6 +18,7 @@ limitations under the License.
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as languageHandler from '../../../languageHandler'; import * as languageHandler from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
@ -36,27 +37,28 @@ module.exports = React.createClass({
displayName: 'Login', displayName: 'Login',
propTypes: { propTypes: {
onLoggedIn: React.PropTypes.func.isRequired, onLoggedIn: PropTypes.func.isRequired,
enableGuest: React.PropTypes.bool, enableGuest: PropTypes.bool,
customHsUrl: React.PropTypes.string, customHsUrl: PropTypes.string,
customIsUrl: React.PropTypes.string, customIsUrl: PropTypes.string,
defaultHsUrl: React.PropTypes.string, defaultHsUrl: PropTypes.string,
defaultIsUrl: React.PropTypes.string, defaultIsUrl: PropTypes.string,
// Secondary HS which we try to log into if the user is using // Secondary HS which we try to log into if the user is using
// the default HS but login fails. Useful for migrating to a // the default HS but login fails. Useful for migrating to a
// different home server without confusing users. // different home server without confusing users.
fallbackHsUrl: React.PropTypes.string, fallbackHsUrl: PropTypes.string,
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: PropTypes.string,
// login shouldn't know or care how registration is done. // login shouldn't know or care how registration is done.
onRegisterClick: React.PropTypes.func.isRequired, onRegisterClick: PropTypes.func.isRequired,
// login shouldn't care how password recovery is done. // login shouldn't care how password recovery is done.
onForgotPasswordClick: React.PropTypes.func, onForgotPasswordClick: PropTypes.func,
onCancelClick: React.PropTypes.func, onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -217,6 +219,8 @@ module.exports = React.createClass({
if (config.isUrl !== undefined) { if (config.isUrl !== undefined) {
newState.enteredIdentityServerUrl = config.isUrl; newState.enteredIdentityServerUrl = config.isUrl;
} }
this.props.onServerConfigChange(config);
this.setState(newState, function() { this.setState(newState, function() {
self._initLoginLogic(config.hsUrl || null, config.isUrl); self._initLoginLogic(config.hsUrl || null, config.isUrl);
}); });
@ -427,10 +431,10 @@ module.exports = React.createClass({
// FIXME: remove status.im theme tweaks // FIXME: remove status.im theme tweaks
const theme = SettingsStore.getValue("theme"); const theme = SettingsStore.getValue("theme");
if (theme !== "status") { if (theme !== "status") {
header = <h2>{ _t('Sign in') }</h2>; header = <h2>{ _t('Sign in') } { loader }</h2>;
} else { } else {
if (!this.state.errorText) { if (!this.state.errorText) {
header = <h2>{ _t('Sign in to get started') }</h2>; header = <h2>{ _t('Sign in to get started') } { loader }</h2>;
} }
} }

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict'; 'use strict';
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 { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -25,7 +26,7 @@ module.exports = React.createClass({
displayName: 'PostRegistration', displayName: 'PostRegistration',
propTypes: { propTypes: {
onComplete: React.PropTypes.func.isRequired, onComplete: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -19,6 +19,7 @@ import Matrix from 'matrix-js-sdk';
import Promise from 'bluebird'; import Promise from 'bluebird';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import ServerConfig from '../../views/login/ServerConfig'; import ServerConfig from '../../views/login/ServerConfig';
@ -35,31 +36,32 @@ module.exports = React.createClass({
displayName: 'Registration', displayName: 'Registration',
propTypes: { propTypes: {
onLoggedIn: React.PropTypes.func.isRequired, onLoggedIn: PropTypes.func.isRequired,
clientSecret: React.PropTypes.string, clientSecret: PropTypes.string,
sessionId: React.PropTypes.string, sessionId: PropTypes.string,
makeRegistrationUrl: React.PropTypes.func.isRequired, makeRegistrationUrl: PropTypes.func.isRequired,
idSid: React.PropTypes.string, idSid: PropTypes.string,
customHsUrl: React.PropTypes.string, customHsUrl: PropTypes.string,
customIsUrl: React.PropTypes.string, customIsUrl: PropTypes.string,
defaultHsUrl: React.PropTypes.string, defaultHsUrl: PropTypes.string,
defaultIsUrl: React.PropTypes.string, defaultIsUrl: PropTypes.string,
brand: React.PropTypes.string, brand: PropTypes.string,
email: React.PropTypes.string, email: PropTypes.string,
referrer: React.PropTypes.string, referrer: PropTypes.string,
teamServerConfig: React.PropTypes.shape({ teamServerConfig: PropTypes.shape({
// Email address to request new teams // Email address to request new teams
supportEmail: React.PropTypes.string.isRequired, supportEmail: PropTypes.string.isRequired,
// URL of the riot-team-server to get team configurations and track referrals // URL of the riot-team-server to get team configurations and track referrals
teamServerURL: React.PropTypes.string.isRequired, teamServerURL: PropTypes.string.isRequired,
}), }),
teamSelected: React.PropTypes.object, teamSelected: PropTypes.object,
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: PropTypes.string,
// registration shouldn't know or care how login is done. // registration shouldn't know or care how login is done.
onLoginClick: React.PropTypes.func.isRequired, onLoginClick: PropTypes.func.isRequired,
onCancelClick: React.PropTypes.func, onCancelClick: PropTypes.func,
onServerConfigChange: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -130,6 +132,7 @@ module.exports = React.createClass({
if (config.isUrl !== undefined) { if (config.isUrl !== undefined) {
newState.isUrl = config.isUrl; newState.isUrl = config.isUrl;
} }
this.props.onServerConfigChange(config);
this.setState(newState, function() { this.setState(newState, function() {
this._replaceClient(); this._replaceClient();
}); });

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,6 +16,8 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import AvatarLogic from '../../../Avatar'; import AvatarLogic from '../../../Avatar';
import sdk from '../../../index'; import sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
@ -23,16 +26,20 @@ module.exports = React.createClass({
displayName: 'BaseAvatar', displayName: 'BaseAvatar',
propTypes: { propTypes: {
name: React.PropTypes.string.isRequired, // The name (first initial used as default) name: PropTypes.string.isRequired, // The name (first initial used as default)
idName: React.PropTypes.string, // ID for generating hash colours idName: PropTypes.string, // ID for generating hash colours
title: React.PropTypes.string, // onHover title text title: PropTypes.string, // onHover title text
url: React.PropTypes.string, // highest priority of them all, shortcut to set in urls[0] url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
urls: React.PropTypes.array, // [highest_priority, ... , lowest_priority] urls: PropTypes.array, // [highest_priority, ... , lowest_priority]
width: React.PropTypes.number, width: PropTypes.number,
height: React.PropTypes.number, height: PropTypes.number,
// XXX resizeMethod not actually used. // XXX resizeMethod not actually used.
resizeMethod: React.PropTypes.string, resizeMethod: PropTypes.string,
defaultToInitialLetter: React.PropTypes.bool, // true to add default url defaultToInitialLetter: PropTypes.bool, // true to add default url
},
contextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -48,6 +55,16 @@ module.exports = React.createClass({
return this._getState(this.props); return this._getState(this.props);
}, },
componentWillMount() {
this.unmounted = false;
this.context.matrixClient.on('sync', this.onClientSync);
},
componentWillUnmount() {
this.unmounted = true;
this.context.matrixClient.removeListener('sync', this.onClientSync);
},
componentWillReceiveProps: function(nextProps) { componentWillReceiveProps: function(nextProps) {
// work out if we need to call setState (if the image URLs array has changed) // work out if we need to call setState (if the image URLs array has changed)
const newState = this._getState(nextProps); const newState = this._getState(nextProps);
@ -66,6 +83,23 @@ module.exports = React.createClass({
} }
}, },
onClientSync: function(syncState, prevState) {
if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING or PREPARED.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected &&
// Did we fall back?
this.state.urlsIndex > 0
) {
// Start from the highest priority URL again
this.setState({
urlsIndex: 0,
});
}
},
_getState: function(props) { _getState: function(props) {
// work out the full set of urls to try to load. This is formed like so: // work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, props.urls, default image ] // imageUrls: [ props.url, props.urls, default image ]

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict'; 'use strict';
const React = require('react'); const React = require('react');
import PropTypes from 'prop-types';
const Avatar = require('../../../Avatar'); const Avatar = require('../../../Avatar');
const sdk = require("../../../index"); const sdk = require("../../../index");
const dispatcher = require("../../../dispatcher"); const dispatcher = require("../../../dispatcher");
@ -25,15 +26,15 @@ module.exports = React.createClass({
displayName: 'MemberAvatar', displayName: 'MemberAvatar',
propTypes: { propTypes: {
member: React.PropTypes.object.isRequired, member: PropTypes.object.isRequired,
width: React.PropTypes.number, width: PropTypes.number,
height: React.PropTypes.number, height: PropTypes.number,
resizeMethod: React.PropTypes.string, resizeMethod: PropTypes.string,
// The onClick to give the avatar // The onClick to give the avatar
onClick: React.PropTypes.func, onClick: PropTypes.func,
// Whether the onClick of the avatar should be overriden to dispatch 'view_user' // Whether the onClick of the avatar should be overriden to dispatch 'view_user'
viewUserOnClick: React.PropTypes.bool, viewUserOnClick: PropTypes.bool,
title: React.PropTypes.string, title: PropTypes.string,
}, },
getDefaultProps: function() { getDefaultProps: function() {

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 React from "react";
import PropTypes from 'prop-types';
import {ContentRepo} from "matrix-js-sdk"; import {ContentRepo} from "matrix-js-sdk";
import MatrixClientPeg from "../../../MatrixClientPeg"; import MatrixClientPeg from "../../../MatrixClientPeg";
import sdk from "../../../index"; import sdk from "../../../index";
@ -25,11 +26,11 @@ module.exports = React.createClass({
// oobData.avatarUrl should be set (else there // oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from) // would be nowhere to get the avatar from)
propTypes: { propTypes: {
room: React.PropTypes.object, room: PropTypes.object,
oobData: React.PropTypes.object, oobData: PropTypes.object,
width: React.PropTypes.number, width: PropTypes.number,
height: React.PropTypes.number, height: PropTypes.number,
resizeMethod: React.PropTypes.string, resizeMethod: PropTypes.string,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -47,12 +48,34 @@ module.exports = React.createClass({
}; };
}, },
componentWillMount: function() {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
},
componentWillUnmount: function() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this.onRoomStateEvents);
}
},
componentWillReceiveProps: function(newProps) { componentWillReceiveProps: function(newProps) {
this.setState({ this.setState({
urls: this.getImageUrls(newProps), urls: this.getImageUrls(newProps),
}); });
}, },
onRoomStateEvents: function(ev) {
if (!this.props.room ||
ev.getRoomId() !== this.props.room.roomId ||
ev.getType() !== 'm.room.avatar'
) return;
this.setState({
urls: this.getImageUrls(this.props),
});
},
getImageUrls: function(props) { getImageUrls: function(props) {
return [ return [
ContentRepo.getHttpUriForMxc( ContentRepo.getHttpUriForMxc(
@ -86,10 +109,15 @@ module.exports = React.createClass({
const mlist = props.room.currentState.members; const mlist = props.room.currentState.members;
const userIds = []; const userIds = [];
const leftUserIds = [];
// for .. in optimisation to return early if there are >2 keys // for .. in optimisation to return early if there are >2 keys
for (const uid in mlist) { for (const uid in mlist) {
if (mlist.hasOwnProperty(uid)) { if (mlist.hasOwnProperty(uid)) {
if (["join", "invite"].includes(mlist[uid].membership)) {
userIds.push(uid); userIds.push(uid);
} else {
leftUserIds.push(uid);
}
} }
if (userIds.length > 2) { if (userIds.length > 2) {
return null; return null;
@ -111,6 +139,14 @@ module.exports = React.createClass({
false, false,
); );
} else if (userIds.length == 1) { } else if (userIds.length == 1) {
// The other 1-1 user left, leaving just the current user, so show the left user's avatar
if (leftUserIds.length === 1) {
return mlist[leftUserIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
props.width, props.height, props.resizeMethod,
false,
);
}
return mlist[userIds[0]].getAvatarUrl( return mlist[userIds[0]].getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
Math.floor(props.width * window.devicePixelRatio), Math.floor(props.width * window.devicePixelRatio),

View file

@ -17,11 +17,12 @@ limitations under the License.
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'CreateRoomButton', displayName: 'CreateRoomButton',
propTypes: { propTypes: {
onCreateRoom: React.PropTypes.func, onCreateRoom: PropTypes.func,
}, },
getDefaultProps: function() { getDefaultProps: function() {

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict'; 'use strict';
const React = require('react'); const React = require('react');
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
const Presets = { const Presets = {
@ -28,8 +29,8 @@ const Presets = {
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'CreateRoomPresets', displayName: 'CreateRoomPresets',
propTypes: { propTypes: {
onChange: React.PropTypes.func, onChange: PropTypes.func,
preset: React.PropTypes.string, preset: PropTypes.string,
}, },
Presets: Presets, Presets: Presets,

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
const React = require('react'); const React = require('react');
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
module.exports = React.createClass({ module.exports = React.createClass({
@ -22,9 +23,9 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// Specifying a homeserver will make magical things happen when you, // Specifying a homeserver will make magical things happen when you,
// e.g. start typing in the room alias box. // e.g. start typing in the room alias box.
homeserver: React.PropTypes.string, homeserver: PropTypes.string,
alias: React.PropTypes.string, alias: PropTypes.string,
onChange: React.PropTypes.func, onChange: PropTypes.func,
}, },
getDefaultProps: function() { getDefaultProps: function() {

View file

@ -20,7 +20,6 @@ import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import AccessibleButton from '../elements/AccessibleButton';
import Promise from 'bluebird'; import Promise from 'bluebird';
import { addressTypes, getAddressType } from '../../../UserAddress.js'; import { addressTypes, getAddressType } from '../../../UserAddress.js';
import GroupStoreCache from '../../../stores/GroupStoreCache'; import GroupStoreCache from '../../../stores/GroupStoreCache';
@ -507,7 +506,8 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const AddressSelector = sdk.getComponent("elements.AddressSelector"); const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null; this.scrollElement = null;
@ -580,14 +580,8 @@ module.exports = React.createClass({
} }
return ( return (
<div className="mx_ChatInviteDialog" onKeyDown={this.onKeyDown}> <BaseDialog className="mx_ChatInviteDialog" onKeyDown={this.onKeyDown}
<div className="mx_Dialog_title"> onFinished={this.props.onFinished} title={this.props.title}>
{ this.props.title }
</div>
<AccessibleButton className="mx_ChatInviteDialog_cancel"
onClick={this.onCancel} >
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</AccessibleButton>
<div className="mx_ChatInviteDialog_label"> <div className="mx_ChatInviteDialog_label">
<label htmlFor="textinput">{ this.props.description }</label> <label htmlFor="textinput">{ this.props.description }</label>
</div> </div>
@ -597,12 +591,10 @@ module.exports = React.createClass({
{ addressSelector } { addressSelector }
{ this.props.extraNode } { this.props.extraNode }
</div> </div>
<div className="mx_Dialog_buttons"> <DialogButtons primaryButton={this.props.button}
<button className="mx_Dialog_primary" onClick={this.onButtonClick}> onPrimaryButtonClick={this.onButtonClick}
{ this.props.button } onCancel={this.onCancel} />
</button> </BaseDialog>
</div>
</div>
); );
}, },
}); });

View file

@ -15,10 +15,15 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import FocusTrap from 'focus-trap-react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
import { KeyCode } from '../../../Keyboard'; import { KeyCode } from '../../../Keyboard';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
/** /**
* Basic container for modal dialogs. * Basic container for modal dialogs.
@ -31,33 +36,48 @@ export default React.createClass({
propTypes: { propTypes: {
// onFinished callback to call when Escape is pressed // onFinished callback to call when Escape is pressed
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
// callback to call when Enter is pressed // called when a key is pressed
onEnterPressed: React.PropTypes.func, onKeyDown: PropTypes.func,
// CSS class to apply to dialog div // CSS class to apply to dialog div
className: React.PropTypes.string, className: PropTypes.string,
// Title for the dialog. // Title for the dialog.
// (could probably actually be something more complicated than a string if desired) // (could probably actually be something more complicated than a string if desired)
title: React.PropTypes.string.isRequired, title: PropTypes.string.isRequired,
// children should be the content of the dialog // children should be the content of the dialog
children: React.PropTypes.node, children: PropTypes.node,
// Id of content element
// If provided, this is used to add a aria-describedby attribute
contentId: React.PropTypes.string,
},
childContextTypes: {
matrixClient: PropTypes.instanceOf(MatrixClient),
},
getChildContext: function() {
return {
matrixClient: this._matrixClient,
};
},
componentWillMount() {
this._matrixClient = MatrixClientPeg.get();
}, },
_onKeyDown: function(e) { _onKeyDown: function(e) {
if (this.props.onKeyDown) {
this.props.onKeyDown(e);
}
if (e.keyCode === KeyCode.ESCAPE) { if (e.keyCode === KeyCode.ESCAPE) {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.props.onFinished(); this.props.onFinished();
} else if (e.keyCode === KeyCode.ENTER) {
if (this.props.onEnterPressed) {
e.stopPropagation();
e.preventDefault();
this.props.onEnterPressed(e);
}
} }
}, },
@ -69,17 +89,28 @@ export default React.createClass({
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
return ( return (
<div onKeyDown={this._onKeyDown} className={this.props.className}> <FocusTrap onKeyDown={this._onKeyDown}
className={this.props.className}
role="dialog"
aria-labelledby='mx_BaseDialog_title'
// This should point to a node describing the dialog.
// If we were about to completelly follow this recommendation we'd need to
// make all the components relying on BaseDialog to be aware of it.
// So instead we will use the whole content as the description.
// Description comes first and if the content contains more text,
// AT users can skip its presentation.
aria-describedby={this.props.contentId}
>
<AccessibleButton onClick={this._onCancelClick} <AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton" className="mx_Dialog_cancelButton"
> >
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" /> <TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</AccessibleButton> </AccessibleButton>
<div className='mx_Dialog_title'> <div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'>
{ this.props.title } { this.props.title }
</div> </div>
{ this.props.children } { this.props.children }
</div> </FocusTrap>
); );
}, },
}); });

View file

@ -15,6 +15,7 @@ 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 { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
@ -58,6 +59,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
); );
tiles.push( tiles.push(
<RoomTile key={room.roomId} room={room} <RoomTile key={room.roomId} room={room}
transparent={true}
collapsed={false} collapsed={false}
selected={false} selected={false}
unread={Unread.doesRoomHaveUnreadMessages(room)} unread={Unread.doesRoomHaveUnreadMessages(room)}
@ -127,7 +129,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
</div> </div>
<div className={labelClasses}><i>{ _t("Start new chat") }</i></div> <div className={labelClasses}><i>{ _t("Start new chat") }</i></div>
</AccessibleButton>; </AccessibleButton>;
content = <div className="mx_Dialog_content"> content = <div className="mx_Dialog_content" id='mx_Dialog_content'>
{ _t('You already have existing direct chats with this user:') } { _t('You already have existing direct chats with this user:') }
<div className="mx_ChatCreateOrReuseDialog_tiles"> <div className="mx_ChatCreateOrReuseDialog_tiles">
{ this.state.tiles } { this.state.tiles }
@ -137,6 +139,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
} else { } else {
// Show the avatar, name and a button to confirm that a new chat is requested // Show the avatar, name and a button to confirm that a new chat is requested
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Spinner = sdk.getComponent('elements.Spinner'); const Spinner = sdk.getComponent('elements.Spinner');
title = _t('Start chatting'); title = _t('Start chatting');
@ -144,7 +147,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
if (this.state.busyProfile) { if (this.state.busyProfile) {
profile = <Spinner />; profile = <Spinner />;
} else if (this.state.profileError) { } else if (this.state.profileError) {
profile = <div className="error"> profile = <div className="error" role="alert">
Unable to load profile information for { this.props.userId } Unable to load profile information for { this.props.userId }
</div>; </div>;
} else { } else {
@ -160,17 +163,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
</div>; </div>;
} }
content = <div> content = <div>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content" id='mx_Dialog_content'>
<p> <p>
{ _t('Click on the button below to start chatting!') } { _t('Click on the button below to start chatting!') }
</p> </p>
{ profile } { profile }
</div> </div>
<div className="mx_Dialog_buttons"> <DialogButtons primaryButton={_t('Start Chatting')}
<button className="mx_Dialog_primary" onClick={this.props.onNewDMClick}> onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
{ _t('Start Chatting') }
</button>
</div>
</div>; </div>;
} }
@ -179,6 +179,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
<BaseDialog className='mx_ChatCreateOrReuseDialog' <BaseDialog className='mx_ChatCreateOrReuseDialog'
onFinished={this.props.onFinished.bind(false)} onFinished={this.props.onFinished.bind(false)}
title={title} title={title}
contentId='mx_Dialog_content'
> >
{ content } { content }
</BaseDialog> </BaseDialog>
@ -187,9 +188,9 @@ export default class ChatCreateOrReuseDialog extends React.Component {
} }
ChatCreateOrReuseDialog.propTyps = { ChatCreateOrReuseDialog.propTyps = {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
// Called when clicking outside of the dialog // Called when clicking outside of the dialog
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
onNewDMClick: React.PropTypes.func.isRequired, onNewDMClick: PropTypes.func.isRequired,
onExistingRoomSelected: React.PropTypes.func.isRequired, onExistingRoomSelected: PropTypes.func.isRequired,
}; };

View file

@ -15,10 +15,10 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk'; 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 { GroupMemberType } from '../../../groups'; import { GroupMemberType } from '../../../groups';
/* /*
@ -33,20 +33,20 @@ export default React.createClass({
displayName: 'ConfirmUserActionDialog', displayName: 'ConfirmUserActionDialog',
propTypes: { propTypes: {
// matrix-js-sdk (room) member object. Supply either this or 'groupMember' // matrix-js-sdk (room) member object. Supply either this or 'groupMember'
member: React.PropTypes.object, member: 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 // needed if a group member is specified
matrixClient: React.PropTypes.instanceOf(MatrixClient), matrixClient: PropTypes.instanceOf(MatrixClient),
action: React.PropTypes.string.isRequired, // eg. 'Ban' action: PropTypes.string.isRequired, // eg. 'Ban'
title: React.PropTypes.string.isRequired, // eg. 'Ban this user?' title: 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
// be the string entered. // be the string entered.
askReason: React.PropTypes.bool, askReason: PropTypes.bool,
danger: React.PropTypes.bool, danger: PropTypes.bool,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
defaultProps: { defaultProps: {
@ -76,13 +76,11 @@ export default React.createClass({
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
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 confirmButtonClass = classnames({ const confirmButtonClass = this.props.danger ? 'danger' : '';
'mx_Dialog_primary': true,
'danger': this.props.danger,
});
let reasonBox; let reasonBox;
if (this.props.askReason) { if (this.props.askReason) {
@ -116,10 +114,10 @@ export default React.createClass({
return ( return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content'
> >
<div className="mx_Dialog_content"> <div id="mx_Dialog_content" className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_avatar"> <div className="mx_ConfirmUserActionDialog_avatar">
{ avatar } { avatar }
</div> </div>
@ -127,17 +125,11 @@ export default React.createClass({
<div className="mx_ConfirmUserActionDialog_userId">{ userId }</div> <div className="mx_ConfirmUserActionDialog_userId">{ userId }</div>
</div> </div>
{ reasonBox } { reasonBox }
<div className="mx_Dialog_buttons"> <DialogButtons primaryButton={this.props.action}
<button className={confirmButtonClass} onPrimaryButtonClick={this.onOk}
onClick={this.onOk} autoFocus={!this.props.askReason} primaryButtonClass={confirmButtonClass}
> focus={!this.props.askReason}
{ this.props.action } onCancel={this.onCancel} />
</button>
<button onClick={this.onCancel}>
{ _t("Cancel") }
</button>
</div>
</BaseDialog> </BaseDialog>
); );
}, },

View file

@ -55,11 +55,15 @@ export default React.createClass({
_checkGroupId: function(e) { _checkGroupId: function(e) {
let error = null; let error = null;
if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) { if (!this.state.groupId) {
error = _t("Community IDs cannot not be empty.");
} else if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) {
error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'"); error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'");
} }
this.setState({ this.setState({
groupIdError: error, groupIdError: error,
// Reset createError to get rid of now stale error message
createError: null,
}); });
return error; return error;
}, },
@ -108,7 +112,7 @@ export default React.createClass({
// XXX: We should catch errcodes and give sensible i18ned messages for them, // XXX: We should catch errcodes and give sensible i18ned messages for them,
// 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" role="alert">
<div>{ _t('Something went wrong whilst creating your community') }</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>;
@ -116,7 +120,6 @@ 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}
title={_t('Create Community')} title={_t('Create Community')}
> >
<form onSubmit={this._onFormSubmit}> <form onSubmit={this._onFormSubmit}>
@ -159,10 +162,10 @@ export default React.createClass({
{ createErrorNode } { createErrorNode }
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<input type="submit" value={_t('Create')} className="mx_Dialog_primary" />
<button onClick={this._onCancel}> <button onClick={this._onCancel}>
{ _t("Cancel") } { _t("Cancel") }
</button> </button>
<input type="submit" value={_t('Create')} className="mx_Dialog_primary" />
</div> </div>
</form> </form>
</BaseDialog> </BaseDialog>

View file

@ -15,6 +15,7 @@ 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 SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -22,7 +23,7 @@ import { _t } from '../../../languageHandler';
export default React.createClass({ export default React.createClass({
displayName: 'CreateRoomDialog', displayName: 'CreateRoomDialog',
propTypes: { propTypes: {
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
componentDidMount: function() { componentDidMount: function() {
@ -41,11 +42,12 @@ export default React.createClass({
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={_t('Create Room')} title={_t('Create Room')}
> >
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_CreateRoomDialog_label"> <div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label> <label htmlFor="textinput"> { _t('Room name (optional)') } </label>
@ -67,14 +69,10 @@ export default React.createClass({
</div> </div>
</details> </details>
</div> </div>
<div className="mx_Dialog_buttons"> </form>
<button onClick={this.onCancel}> <DialogButtons primaryButton={_t('Create Room')}
{ _t('Cancel') } onPrimaryButtonClick={this.onOk}
</button> onCancel={this.onCancel} />
<button className="mx_Dialog_primary" onClick={this.onOk}>
{ _t('Create Room') }
</button>
</div>
</BaseDialog> </BaseDialog>
); );
}, },

View file

@ -15,6 +15,7 @@ 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 Analytics from '../../../Analytics'; import Analytics from '../../../Analytics';
@ -77,6 +78,7 @@ export default class DeactivateAccountDialog extends React.Component {
} }
render() { render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
let passwordBoxClass = ''; let passwordBoxClass = '';
@ -99,10 +101,11 @@ export default class DeactivateAccountDialog extends React.Component {
} }
return ( return (
<div className="mx_DeactivateAccountDialog"> <BaseDialog className="mx_DeactivateAccountDialog"
<div className="mx_Dialog_title danger"> onFinished={this.props.onFinished}
{ _t("Deactivate Account") } onEnterPressed={this.onOk}
</div> titleClass="danger"
title={_t("Deactivate Account")}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<p>{ _t("This will make your account permanently unusable. You will not be able to re-register the same user ID.") }</p> <p>{ _t("This will make your account permanently unusable. You will not be able to re-register the same user ID.") }</p>
@ -130,11 +133,11 @@ export default class DeactivateAccountDialog extends React.Component {
{ cancelButton } { cancelButton }
</div> </div>
</div> </BaseDialog>
); );
} }
} }
DeactivateAccountDialog.propTypes = { DeactivateAccountDialog.propTypes = {
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}; };

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index'; import sdk from '../../../index';
import * as FormattingUtils from '../../../utils/FormattingUtils'; import * as FormattingUtils from '../../../utils/FormattingUtils';
@ -71,7 +72,7 @@ export default function DeviceVerifyDialog(props) {
} }
DeviceVerifyDialog.propTypes = { DeviceVerifyDialog.propTypes = {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
device: React.PropTypes.object.isRequired, device: PropTypes.object.isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}; };

View file

@ -26,20 +26,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 { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
export default React.createClass({ export default React.createClass({
displayName: 'ErrorDialog', displayName: 'ErrorDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, title: PropTypes.string,
description: React.PropTypes.oneOfType([ description: PropTypes.oneOfType([
React.PropTypes.element, PropTypes.element,
React.PropTypes.string, PropTypes.string,
]), ]),
button: React.PropTypes.string, button: PropTypes.string,
focus: React.PropTypes.bool, focus: PropTypes.bool,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -51,22 +52,18 @@ export default React.createClass({
}; };
}, },
componentDidMount: function() {
if (this.props.focus) {
this.refs.button.focus();
}
},
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={this.props.title || _t('Error')}> title={this.props.title || _t('Error')}
<div className="mx_Dialog_content"> contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description || _t('An error has occurred.') } { this.props.description || _t('An error has occurred.') }
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button ref="button" className="mx_Dialog_primary" onClick={this.props.onFinished}> <button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
{ this.props.button || _t('OK') } { this.props.button || _t('OK') }
</button> </button>
</div> </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 sdk from '../../../index'; import sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -27,22 +28,22 @@ export default React.createClass({
propTypes: { propTypes: {
// matrix client to use for UI auth requests // matrix client to use for UI auth requests
matrixClient: React.PropTypes.object.isRequired, matrixClient: PropTypes.object.isRequired,
// response from initial request. If not supplied, will do a request on // response from initial request. If not supplied, will do a request on
// mount. // mount.
authData: React.PropTypes.shape({ authData: PropTypes.shape({
flows: React.PropTypes.array, flows: PropTypes.array,
params: React.PropTypes.object, params: PropTypes.object,
session: React.PropTypes.string, session: PropTypes.string,
}), }),
// callback // callback
makeRequest: React.PropTypes.func.isRequired, makeRequest: PropTypes.func.isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
title: React.PropTypes.string, title: PropTypes.string,
}, },
getInitialState: function() { getInitialState: function() {
@ -72,11 +73,12 @@ export default React.createClass({
let content; let content;
if (this.state.authError) { if (this.state.authError) {
content = ( content = (
<div> <div id='mx_Dialog_content'>
<div>{ this.state.authError.message || this.state.authError.toString() }</div> <div role="alert">{ this.state.authError.message || this.state.authError.toString() }</div>
<br /> <br />
<AccessibleButton onClick={this._onDismissClick} <AccessibleButton onClick={this._onDismissClick}
className="mx_UserSettings_button" className="mx_UserSettings_button"
autoFocus="true"
> >
{ _t("Dismiss") } { _t("Dismiss") }
</AccessibleButton> </AccessibleButton>
@ -84,7 +86,7 @@ export default React.createClass({
); );
} else { } else {
content = ( content = (
<div> <div id='mx_Dialog_content'>
<InteractiveAuth ref={this._collectInteractiveAuth} <InteractiveAuth ref={this._collectInteractiveAuth}
matrixClient={this.props.matrixClient} matrixClient={this.props.matrixClient}
authData={this.props.authData} authData={this.props.authData}
@ -99,6 +101,7 @@ export default React.createClass({
<BaseDialog className="mx_InteractiveAuthDialog" <BaseDialog className="mx_InteractiveAuthDialog"
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this.state.authError ? 'Error' : (this.props.title || _t('Authentication'))} title={this.state.authError ? 'Error' : (this.props.title || _t('Authentication'))}
contentId='mx_Dialog_content'
> >
{ content } { content }
</BaseDialog> </BaseDialog>

View file

@ -16,6 +16,7 @@ limitations under the License.
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
@ -30,10 +31,10 @@ import { _t, _td } from '../../../languageHandler';
*/ */
export default React.createClass({ export default React.createClass({
propTypes: { propTypes: {
matrixClient: React.PropTypes.object.isRequired, matrixClient: PropTypes.object.isRequired,
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
deviceId: React.PropTypes.string.isRequired, deviceId: PropTypes.string.isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -125,11 +126,11 @@ export default React.createClass({
text = _t(text, {displayName: displayName}); text = _t(text, {displayName: displayName});
return ( return (
<div> <div id='mx_Dialog_content'>
<p>{ text }</p> <p>{ text }</p>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this._onVerifyClicked}> <button onClick={this._onVerifyClicked} autoFocus="true">
{ _t('Start verification') } { _t('Start verification') }
</button> </button>
<button onClick={this._onShareClicked}> <button onClick={this._onShareClicked}>
@ -153,7 +154,7 @@ export default React.createClass({
content = this._renderContent(); content = this._renderContent();
} else { } else {
content = ( content = (
<div> <div id='mx_Dialog_content'>
<p>{ _t('Loading device info...') }</p> <p>{ _t('Loading device info...') }</p>
<Spinner /> <Spinner />
</div> </div>
@ -164,6 +165,7 @@ export default React.createClass({
<BaseDialog className='mx_KeyShareRequestDialog' <BaseDialog className='mx_KeyShareRequestDialog'
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t('Encryption key request')} title={_t('Encryption key request')}
contentId='mx_Dialog_content'
> >
{ content } { content }
</BaseDialog> </BaseDialog>

View file

@ -16,20 +16,20 @@ 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 { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import classnames from 'classnames';
export default React.createClass({ export default React.createClass({
displayName: 'QuestionDialog', displayName: 'QuestionDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, title: PropTypes.string,
description: React.PropTypes.node, description: PropTypes.node,
extraButtons: React.PropTypes.node, extraButtons: PropTypes.node,
button: React.PropTypes.string, button: PropTypes.string,
danger: React.PropTypes.bool, danger: PropTypes.bool,
focus: React.PropTypes.bool, focus: PropTypes.bool,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -53,30 +53,27 @@ export default React.createClass({
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const cancelButton = this.props.hasCancelButton ? ( const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
<button onClick={this.onCancel}> let primaryButtonClass = "";
{ _t("Cancel") } if (this.props.danger) {
</button> primaryButtonClass = "danger";
) : null; }
const buttonClasses = classnames({
mx_Dialog_primary: true,
danger: this.props.danger,
});
return ( return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content'
> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description } { this.props.description }
</div> </div>
<div className="mx_Dialog_buttons"> <DialogButtons primaryButton={this.props.button || _t('OK')}
<button className={buttonClasses} onClick={this.onOk} autoFocus={this.props.focus}> onPrimaryButtonClick={this.onOk}
{ this.props.button || _t('OK') } primaryButtonClass={primaryButtonClass}
</button> focus={this.props.focus}
onCancel={this.onCancel}
>
{ this.props.extraButtons } { this.props.extraButtons }
{ cancelButton } </DialogButtons>
</div>
</BaseDialog> </BaseDialog>
); );
}, },

View file

@ -15,6 +15,7 @@ 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 SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
@ -25,8 +26,14 @@ export default React.createClass({
displayName: 'SessionRestoreErrorDialog', displayName: 'SessionRestoreErrorDialog',
propTypes: { propTypes: {
error: React.PropTypes.string.isRequired, error: PropTypes.string.isRequired,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
},
componentDidMount: function() {
if (this.refs.bugreportLink) {
this.refs.bugreportLink.focus();
}
}, },
_sendBugReport: function() { _sendBugReport: function() {
@ -40,6 +47,7 @@ export default React.createClass({
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
let bugreport; let bugreport;
if (SdkConfig.get().bug_report_endpoint_url) { if (SdkConfig.get().bug_report_endpoint_url) {
@ -48,16 +56,20 @@ export default React.createClass({
{ _t( { _t(
"Otherwise, <a>click here</a> to send a bug report.", "Otherwise, <a>click here</a> to send a bug report.",
{}, {},
{ 'a': (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a> }, { 'a': (sub) => <a ref="bugreportLink" onClick={this._sendBugReport}
key="bugreport" href='#'>{ sub }</a> },
) } ) }
</p> </p>
); );
} }
const shouldFocusContinueButton =!(bugreport==true);
return ( return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={_t('Unable to restore session')}> title={_t('Unable to restore session')}
<div className="mx_Dialog_content"> contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>{ _t("We encountered an error trying to restore your previous session. If " + <p>{ _t("We encountered an error trying to restore your previous session. If " +
"you continue, you will need to log in again, and encrypted chat " + "you continue, you will need to log in again, and encrypted chat " +
"history will be unreadable.") }</p> "history will be unreadable.") }</p>
@ -68,11 +80,9 @@ export default React.createClass({
{ bugreport } { bugreport }
</div> </div>
<div className="mx_Dialog_buttons"> <DialogButtons primaryButton={_t("Continue anyway")}
<button className="mx_Dialog_primary" onClick={this._continueClicked}> onPrimaryButtonClick={this._continueClicked} focus={shouldFocusContinueButton}
{ _t("Continue anyway") } onCancel={this.props.onFinished} />
</button>
</div>
</BaseDialog> </BaseDialog>
); );
}, },

View file

@ -15,6 +15,7 @@ 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 Email from '../../../email'; import Email from '../../../email';
import AddThreepid from '../../../AddThreepid'; import AddThreepid from '../../../AddThreepid';
@ -30,7 +31,7 @@ import Modal from '../../../Modal';
export default React.createClass({ export default React.createClass({
displayName: 'SetEmailDialog', displayName: 'SetEmailDialog',
propTypes: { propTypes: {
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -40,9 +41,6 @@ export default React.createClass({
}; };
}, },
componentDidMount: function() {
},
onEmailAddressChanged: function(value) { onEmailAddressChanged: function(value) {
this.setState({ this.setState({
emailAddress: value, emailAddress: value,
@ -130,6 +128,7 @@ export default React.createClass({
const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText
className="mx_SetEmailDialog_email_input" className="mx_SetEmailDialog_email_input"
autoFocus="true"
placeholder={_t("Email address")} placeholder={_t("Email address")}
placeholderClassName="mx_SetEmailDialog_email_input_placeholder" placeholderClassName="mx_SetEmailDialog_email_input_placeholder"
blurToCancel={false} blurToCancel={false}
@ -139,9 +138,10 @@ export default React.createClass({
<BaseDialog className="mx_SetEmailDialog" <BaseDialog className="mx_SetEmailDialog"
onFinished={this.onCancelled} onFinished={this.onCancelled}
title={this.props.title} title={this.props.title}
contentId='mx_Dialog_content'
> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<p> <p id='mx_Dialog_content'>
{ _t('This will allow you to reset your password and receive notifications.') } { _t('This will allow you to reset your password and receive notifications.') }
</p> </p>
{ emailInput } { emailInput }

View file

@ -17,6 +17,7 @@ limitations under the License.
import Promise from 'bluebird'; import Promise from 'bluebird';
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 classnames from 'classnames'; import classnames from 'classnames';
@ -35,11 +36,11 @@ const USERNAME_CHECK_DEBOUNCE_MS = 250;
export default React.createClass({ export default React.createClass({
displayName: 'SetMxIdDialog', displayName: 'SetMxIdDialog',
propTypes: { propTypes: {
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
// Called when the user requests to register with a different homeserver // Called when the user requests to register with a different homeserver
onDifferentServerClicked: React.PropTypes.func.isRequired, onDifferentServerClicked: PropTypes.func.isRequired,
// Called if the user wants to switch to login instead // Called if the user wants to switch to login instead
onLoginClick: React.PropTypes.func.isRequired, onLoginClick: PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
@ -234,14 +235,14 @@ export default React.createClass({
"error": Boolean(this.state.usernameError), "error": Boolean(this.state.usernameError),
"success": usernameAvailable, "success": usernameAvailable,
}); });
usernameIndicator = <div className={usernameIndicatorClasses}> usernameIndicator = <div className={usernameIndicatorClasses} role="alert">
{ usernameAvailable ? _t('Username available') : this.state.usernameError } { usernameAvailable ? _t('Username available') : this.state.usernameError }
</div>; </div>;
} }
let authErrorIndicator = null; let authErrorIndicator = null;
if (this.state.authError) { if (this.state.authError) {
authErrorIndicator = <div className="error"> authErrorIndicator = <div className="error" role="alert">
{ this.state.authError } { this.state.authError }
</div>; </div>;
} }
@ -253,8 +254,9 @@ export default React.createClass({
<BaseDialog className="mx_SetMxIdDialog" <BaseDialog className="mx_SetMxIdDialog"
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t('To get started, please pick a username!')} title={_t('To get started, please pick a username!')}
contentId='mx_Dialog_content'
> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content" id='mx_Dialog_content'>
<div className="mx_SetMxIdDialog_input_group"> <div className="mx_SetMxIdDialog_input_group">
<input type="text" ref="input_value" value={this.state.username} <input type="text" ref="input_value" value={this.state.username}
autoFocus={true} autoFocus={true}

View file

@ -15,21 +15,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 { _t } from '../../../languageHandler';
export default React.createClass({ export default React.createClass({
displayName: 'TextInputDialog', displayName: 'TextInputDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, title: PropTypes.string,
description: React.PropTypes.oneOfType([ description: PropTypes.oneOfType([
React.PropTypes.element, PropTypes.element,
React.PropTypes.string, PropTypes.string,
]), ]),
value: React.PropTypes.string, value: PropTypes.string,
button: React.PropTypes.string, button: PropTypes.string,
focus: React.PropTypes.bool, focus: PropTypes.bool,
onFinished: React.PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -58,11 +58,12 @@ export default React.createClass({
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title} title={this.props.title}
> >
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label"> <div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> { this.props.description } </label> <label htmlFor="textinput"> { this.props.description } </label>
@ -71,14 +72,10 @@ export default React.createClass({
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" /> <input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
</div> </div>
</div> </div>
<div className="mx_Dialog_buttons"> </form>
<button onClick={this.onCancel}> <DialogButtons primaryButton={this.props.button}
{ _t("Cancel") } onPrimaryButtonClick={this.onOk}
</button> onCancel={this.onCancel} />
<button className="mx_Dialog_primary" onClick={this.onOk}>
{ this.props.button }
</button>
</div>
</BaseDialog> </BaseDialog>
); );
}, },

View file

@ -23,14 +23,7 @@ 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"; import SettingsStore from "../../../settings/SettingsStore";
import { markAllDevicesKnown } from '../../../cryptodevices';
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;
@ -141,7 +134,7 @@ export default React.createClass({
}, },
_onSendAnywayClicked: function() { _onSendAnywayClicked: function() {
markAllDevicesKnown(this.props.devices); markAllDevicesKnown(MatrixClientPeg.get(), this.props.devices);
this.props.onFinished(); this.props.onFinished();
this.props.onSend(); this.props.onSend();
@ -153,6 +146,7 @@ export default React.createClass({
}, },
render: function() { render: function() {
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
if (this.props.devices === null) { if (this.props.devices === null) {
const Spinner = sdk.getComponent("elements.Spinner"); const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />; return <Spinner />;
@ -187,24 +181,18 @@ export default React.createClass({
} }
}); });
}); });
let sendButton; const sendButtonOnClick = haveUnknownDevices ? this._onSendAnywayClicked : this._onSendClicked;
if (haveUnknownDevices) { const sendButtonLabel = haveUnknownDevices ? this.props.sendAnywayLabel : this.props.sendAnywayLabel;
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');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return ( return (
<BaseDialog className='mx_UnknownDeviceDialog' <BaseDialog className='mx_UnknownDeviceDialog'
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t('Room contains unknown devices')} title={_t('Room contains unknown devices')}
contentId='mx_Dialog_content'
> >
<GeminiScrollbar autoshow={false} className="mx_Dialog_content"> <GeminiScrollbarWrapper autoshow={false} className="mx_Dialog_content" id='mx_Dialog_content'>
<h4> <h4>
{ _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) } { _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) }
</h4> </h4>
@ -212,15 +200,10 @@ export default React.createClass({
{ _t("Unknown devices") }: { _t("Unknown devices") }:
<UnknownDeviceList devices={this.props.devices} /> <UnknownDeviceList devices={this.props.devices} />
</GeminiScrollbar> </GeminiScrollbarWrapper>
<div className="mx_Dialog_buttons"> <DialogButtons primaryButton={sendButtonLabel}
{sendButton} onPrimaryButtonClick={sendButtonOnClick}
<button className="mx_Dialog_primary" autoFocus={true} onCancel={this._onDismissClicked} />
onClick={this._onDismissClicked}
>
{_t("Dismiss")}
</button>
</div>
</BaseDialog> </BaseDialog>
); );
// XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point? // XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point?

View file

@ -15,6 +15,9 @@
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { KeyCode } from '../../../Keyboard';
/** /**
* AccessibleButton is a generic wrapper for any element that should be treated * AccessibleButton is a generic wrapper for any element that should be treated
@ -27,8 +30,34 @@ import React from 'react';
export default function AccessibleButton(props) { export default function AccessibleButton(props) {
const {element, onClick, children, ...restProps} = props; const {element, onClick, children, ...restProps} = props;
restProps.onClick = onClick; restProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
// that might receive focus as a result of the AccessibleButtonClick action
// It's because we are using html buttons at a few places e.g. inside dialogs
// And divs which we report as role button to assistive technologies.
// Browsers handle space and enter keypresses differently and we are only adjusting to the
// inconsistencies here
restProps.onKeyDown = function(e) {
if (e.keyCode === KeyCode.ENTER) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.SPACE) {
e.stopPropagation();
e.preventDefault();
}
};
restProps.onKeyUp = function(e) { restProps.onKeyUp = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e); if (e.keyCode === KeyCode.SPACE) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.ENTER) {
e.stopPropagation();
e.preventDefault();
}
}; };
restProps.tabIndex = restProps.tabIndex || "0"; restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button"; restProps.role = "button";
@ -44,9 +73,9 @@ export default function AccessibleButton(props) {
* implemented exactly like a normal onClick handler. * implemented exactly like a normal onClick handler.
*/ */
AccessibleButton.propTypes = { AccessibleButton.propTypes = {
children: React.PropTypes.node, children: PropTypes.node,
element: React.PropTypes.string, element: PropTypes.string,
onClick: React.PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
}; };
AccessibleButton.defaultProps = { AccessibleButton.defaultProps = {

View file

@ -18,6 +18,7 @@ limitations under the License.
'use strict'; 'use strict';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import sdk from '../../../index'; import sdk from '../../../index';
import classNames from 'classnames'; import classNames from 'classnames';
import { UserAddressType } from '../../../UserAddress'; import { UserAddressType } from '../../../UserAddress';
@ -26,17 +27,17 @@ export default React.createClass({
displayName: 'AddressSelector', displayName: 'AddressSelector',
propTypes: { propTypes: {
onSelected: React.PropTypes.func.isRequired, onSelected: PropTypes.func.isRequired,
// List of the addresses to display // List of the addresses to display
addressList: React.PropTypes.arrayOf(UserAddressType).isRequired, addressList: PropTypes.arrayOf(UserAddressType).isRequired,
// Whether to show the address on the address tiles // Whether to show the address on the address tiles
showAddress: React.PropTypes.bool, showAddress: PropTypes.bool,
truncateAt: React.PropTypes.number.isRequired, truncateAt: PropTypes.number.isRequired,
selected: React.PropTypes.number, selected: PropTypes.number,
// Element to put as a header on top of the list // Element to put as a header on top of the list
header: React.PropTypes.node, header: PropTypes.node,
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import sdk from "../../../index"; import sdk from "../../../index";
import MatrixClientPeg from "../../../MatrixClientPeg"; import MatrixClientPeg from "../../../MatrixClientPeg";
@ -28,9 +29,9 @@ export default React.createClass({
propTypes: { propTypes: {
address: UserAddressType.isRequired, address: UserAddressType.isRequired,
canDismiss: React.PropTypes.bool, canDismiss: PropTypes.bool,
onDismissed: React.PropTypes.func, onDismissed: PropTypes.func,
justified: React.PropTypes.bool, justified: PropTypes.bool,
}, },
getDefaultProps: function() { getDefaultProps: function() {

View file

@ -1,4 +1,4 @@
/* /**
Copyright 2017 Vector Creations Ltd Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -19,6 +19,7 @@ limitations under the License.
import url from 'url'; import url from 'url';
import qs from 'querystring'; import qs from 'querystring';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import PlatformPeg from '../../../PlatformPeg'; import PlatformPeg from '../../../PlatformPeg';
import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarAuthClient from '../../../ScalarAuthClient';
@ -35,32 +36,25 @@ import WidgetUtils from '../../../WidgetUtils';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const ENABLE_REACT_PERF = false;
export default React.createClass({ export default class AppTile extends React.Component {
displayName: 'AppTile', constructor(props) {
super(props);
this.state = this._getNewState(props);
propTypes: { this._onWidgetAction = this._onWidgetAction.bind(this);
id: React.PropTypes.string.isRequired, this._onMessage = this._onMessage.bind(this);
url: React.PropTypes.string.isRequired, this._onLoaded = this._onLoaded.bind(this);
name: React.PropTypes.string.isRequired, this._onEditClick = this._onEditClick.bind(this);
room: React.PropTypes.object.isRequired, this._onDeleteClick = this._onDeleteClick.bind(this);
type: React.PropTypes.string.isRequired, this._onSnapshotClick = this._onSnapshotClick.bind(this);
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. this.onClickMenuBar = this.onClickMenuBar.bind(this);
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false. this._onMinimiseClick = this._onMinimiseClick.bind(this);
fullWidth: React.PropTypes.bool, this._onInitialLoad = this._onInitialLoad.bind(this);
// UserId of the current user this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
userId: React.PropTypes.string.isRequired, this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
// UserId of the entity that added / modified the widget }
creatorUserId: React.PropTypes.string,
waitForIframeLoad: React.PropTypes.bool,
},
getDefaultProps() {
return {
url: "",
waitForIframeLoad: true,
};
},
/** /**
* Set initial component state when the App wUrl (widget URL) is being updated. * Set initial component state when the App wUrl (widget URL) is being updated.
@ -82,8 +76,20 @@ export default React.createClass({
error: null, error: null,
deleting: false, deleting: false,
widgetPageTitle: newProps.widgetPageTitle, widgetPageTitle: newProps.widgetPageTitle,
allowedCapabilities: (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) ?
this.props.whitelistCapabilities : [],
requestedCapabilities: [],
}; };
}, }
/**
* Does the widget support a given capability
* @param {[type]} capability Capability to check for
* @return {Boolean} True if capability supported
*/
_hasCapability(capability) {
return this.state.allowedCapabilities.some((c) => {return c === capability;});
}
/** /**
* Add widget instance specific parameters to pass in wUrl * Add widget instance specific parameters to pass in wUrl
@ -111,11 +117,7 @@ export default React.createClass({
u.query = params; u.query = params;
return u.format(); return u.format();
}, }
getInitialState() {
return this._getNewState(this.props);
},
/** /**
* Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api * Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api
@ -139,7 +141,7 @@ export default React.createClass({
} }
} }
return false; return false;
}, }
isMixedContent() { isMixedContent() {
const parentContentProtocol = window.location.protocol; const parentContentProtocol = window.location.protocol;
@ -151,14 +153,36 @@ export default React.createClass({
return true; return true;
} }
return false; return false;
}, }
componentWillMount() { componentWillMount() {
WidgetMessaging.startListening();
WidgetMessaging.addEndpoint(this.props.id, this.props.url);
window.addEventListener('message', this._onMessage, false);
this.setScalarToken(); this.setScalarToken();
}, }
componentDidMount() {
// Legacy Jitsi widget messaging -- TODO replace this with standard widget
// postMessaging API
window.addEventListener('message', this._onMessage, false);
// Widget action listeners
this.dispatcherRef = dis.register(this._onWidgetAction);
}
componentWillUnmount() {
// Widget action listeners
dis.unregister(this.dispatcherRef);
// Widget postMessage listeners
try {
if (this.widgetMessaging) {
this.widgetMessaging.stop();
}
} catch (e) {
console.error('Failed to stop listening for widgetMessaging events', e.message);
}
// Jitsi listener
window.removeEventListener('message', this._onMessage);
}
/** /**
* Adds a scalar token to the widget URL, if required * Adds a scalar token to the widget URL, if required
@ -210,13 +234,7 @@ export default React.createClass({
initialising: false, initialising: false,
}); });
}); });
}, }
componentWillUnmount() {
WidgetMessaging.stopListening();
WidgetMessaging.removeEndpoint(this.props.id, this.props.url);
window.removeEventListener('message', this._onMessage);
},
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (nextProps.url !== this.props.url) { if (nextProps.url !== this.props.url) {
@ -231,8 +249,10 @@ export default React.createClass({
widgetPageTitle: nextProps.widgetPageTitle, widgetPageTitle: nextProps.widgetPageTitle,
}); });
} }
}, }
// Legacy Jitsi widget messaging
// TODO -- This should be replaced with the new widget postMessaging API
_onMessage(event) { _onMessage(event) {
if (this.props.type !== 'jitsi') { if (this.props.type !== 'jitsi') {
return; return;
@ -250,26 +270,47 @@ export default React.createClass({
.document.querySelector('iframe[id^="jitsiConferenceFrame"]'); .document.querySelector('iframe[id^="jitsiConferenceFrame"]');
PlatformPeg.get().setupScreenSharingForIframe(iframe); PlatformPeg.get().setupScreenSharingForIframe(iframe);
} }
}, }
_canUserModify() { _canUserModify() {
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
}, }
_onEditClick(e) { _onEditClick(e) {
console.log("Edit widget ID ", this.props.id); console.log("Edit widget ID ", this.props.id);
if (this.props.onEditClick) {
this.props.onEditClick();
} else {
const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager");
const src = this._scalarClient.getScalarInterfaceUrlForRoom( const src = this._scalarClient.getScalarInterfaceUrlForRoom(
this.props.room.roomId, 'type_' + this.props.type, this.props.id); this.props.room, 'type_' + this.props.type, this.props.id);
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src, src: src,
}, "mx_IntegrationsManager"); }, "mx_IntegrationsManager");
}, }
}
_onSnapshotClick(e) {
console.warn("Requesting widget snapshot");
this.widgetMessaging.getScreenshot()
.catch((err) => {
console.error("Failed to get screenshot", err);
})
.then((screenshot) => {
dis.dispatch({
action: 'picture_snapshot',
file: screenshot,
}, true);
});
}
/* If user has permission to modify widgets, delete the widget, /* If user has permission to modify widgets, delete the widget,
* otherwise revoke access for the widget to load in the user's browser * otherwise revoke access for the widget to load in the user's browser
*/ */
_onDeleteClick() { _onDeleteClick() {
if (this.props.onDeleteClick) {
this.props.onDeleteClick();
} else {
if (this._canUserModify()) { if (this._canUserModify()) {
// Show delete confirmation dialog // Show delete confirmation dialog
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
@ -291,6 +332,7 @@ export default React.createClass({
this.props.id, this.props.id,
).catch((e) => { ).catch((e) => {
console.error('Failed to delete widget', e); console.error('Failed to delete widget', e);
}).finally(() => {
this.setState({deleting: false}); this.setState({deleting: false});
}); });
}, },
@ -299,14 +341,69 @@ export default React.createClass({
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 * Called when widget iframe has finished loading
*/ */
_onLoaded() { _onLoaded() {
if (!this.widgetMessaging) {
this._onInitialLoad();
}
}
/**
* Called on initial load of the widget iframe
*/
_onInitialLoad() {
this.widgetMessaging = new WidgetMessaging(this.props.id, this.props.url, this.refs.appFrame.contentWindow);
this.widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.id} requested capabilities:`, requestedCapabilities);
requestedCapabilities = requestedCapabilities || [];
// Allow whitelisted capabilities
let requestedWhitelistCapabilies = [];
if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) {
requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) {
return this.indexOf(e)>=0;
}, this.props.whitelistCapabilities);
if (requestedWhitelistCapabilies.length > 0 ) {
console.warn(`Widget ${this.props.id} allowing requested, whitelisted properties:`,
requestedWhitelistCapabilies);
}
}
// TODO -- Add UI to warn about and optionally allow requested capabilities
this.setState({
requestedCapabilities,
allowedCapabilities: this.state.allowedCapabilities.concat(requestedWhitelistCapabilies),
});
if (this.props.onCapabilityRequest) {
this.props.onCapabilityRequest(requestedCapabilities);
}
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err);
});
this.setState({loading: false}); this.setState({loading: false});
}, }
_onWidgetAction(payload) {
if (payload.widgetId === this.props.id) {
switch (payload.action) {
case 'm.sticker':
if (this._hasCapability('m.sticker')) {
dis.dispatch({action: 'post_sticker_message', data: payload.data});
} else {
console.warn('Ignoring sticker message. Invalid capability');
}
break;
}
}
}
/** /**
* Set remote content title on AppTile * Set remote content title on AppTile
@ -320,7 +417,7 @@ export default React.createClass({
}, (err) =>{ }, (err) =>{
console.error("Failed to get page title", 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
@ -329,20 +426,20 @@ export default React.createClass({
return _td('Delete widget'); return _td('Delete widget');
} }
return _td('Revoke widget access'); return _td('Revoke widget access');
}, }
/* TODO -- Store permission in account data so that it is persisted across multiple devices */ /* TODO -- Store permission in account data so that it is persisted across multiple devices */
_grantWidgetPermission() { _grantWidgetPermission() {
console.warn('Granting permission to load widget - ', this.state.widgetUrl); console.warn('Granting permission to load widget - ', this.state.widgetUrl);
localStorage.setItem(this.state.widgetPermissionId, true); localStorage.setItem(this.state.widgetPermissionId, true);
this.setState({hasPermissionToLoad: true}); this.setState({hasPermissionToLoad: true});
}, }
_revokeWidgetPermission() { _revokeWidgetPermission() {
console.warn('Revoking permission to load widget - ', this.state.widgetUrl); console.warn('Revoking permission to load widget - ', this.state.widgetUrl);
localStorage.removeItem(this.state.widgetPermissionId); localStorage.removeItem(this.state.widgetPermissionId);
this.setState({hasPermissionToLoad: false}); this.setState({hasPermissionToLoad: false});
}, }
formatAppTileName() { formatAppTileName() {
let appTileName = "No name"; let appTileName = "No name";
@ -350,7 +447,7 @@ export default React.createClass({
appTileName = this.props.name.trim(); appTileName = this.props.name.trim();
} }
return appTileName; return appTileName;
}, }
onClickMenuBar(ev) { onClickMenuBar(ev) {
ev.preventDefault(); ev.preventDefault();
@ -365,16 +462,42 @@ export default React.createClass({
action: 'appsDrawer', action: 'appsDrawer',
show: !this.props.show, show: !this.props.show,
}); });
}, }
_getSafeUrl() { _getSafeUrl() {
const parsedWidgetUrl = url.parse(this.state.widgetUrl); const parsedWidgetUrl = url.parse(this.state.widgetUrl, true);
if (ENABLE_REACT_PERF) {
parsedWidgetUrl.search = null;
parsedWidgetUrl.query.react_perf = true;
}
let safeWidgetUrl = ''; let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
safeWidgetUrl = url.format(parsedWidgetUrl); safeWidgetUrl = url.format(parsedWidgetUrl);
} }
return safeWidgetUrl; return safeWidgetUrl;
}, }
_getTileTitle() {
const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = '';
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
title = this.state.widgetPageTitle;
}
return (
<span>
<b>{ name }</b>
<span>{ title ? titleSpacer : '' }{ title }</span>
</span>
);
}
_onMinimiseClick(e) {
if (this.props.onMinimiseClick) {
this.props.onMinimiseClick();
}
}
render() { render() {
let appTileBody; let appTileBody;
@ -392,9 +515,13 @@ export default React.createClass({
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";
// Additional iframe feature pemissions
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
const iframeFeatures = "microphone; camera; encrypted-media;";
if (this.props.show) { if (this.props.show) {
const loadingElement = ( const loadingElement = (
<div className='mx_AppTileBody mx_AppLoading'> <div>
<MessageSpinner msg='Loading...' /> <MessageSpinner msg='Loading...' />
</div> </div>
); );
@ -409,9 +536,15 @@ export default React.createClass({
); );
} else { } else {
appTileBody = ( appTileBody = (
<div className={this.state.loading ? 'mx_AppTileBody mx_AppLoading' : 'mx_AppTileBody'}> <div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
{ this.state.loading && loadingElement } { this.state.loading && loadingElement }
{ /*
The "is" attribute in the following iframe tag is needed in order to enable rendering of the
"allow" attribute, which is unknown to react 15.
*/ }
<iframe <iframe
is
allow={iframeFeatures}
ref="appFrame" ref="appFrame"
src={this._getSafeUrl()} src={this._getSafeUrl()}
allowFullScreen="true" allowFullScreen="true"
@ -445,29 +578,42 @@ export default React.createClass({
deleteClasses += ' mx_AppTileMenuBarWidgetDelete'; deleteClasses += ' mx_AppTileMenuBarWidgetDelete';
} }
// Picture snapshot - only show button when apps are maximised.
const showPictureSnapshotButton = this._hasCapability('screenshot') && this.props.show;
const showPictureSnapshotIcon = 'img/camera_green.svg';
const windowStateIcon = (this.props.show ? 'img/minimize.svg' : 'img/maximize.svg'); 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}>
{ this.props.showMenubar &&
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}> <div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
<span className="mx_AppTileMenuBarTitle"> <span className="mx_AppTileMenuBarTitle" style={{pointerEvents: (this.props.handleMinimisePointerEvents ? 'all' : false)}}>
<TintableSvgButton { this.props.showMinimise && <TintableSvgButton
src={windowStateIcon} src={windowStateIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding" className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Minimize apps')} title={_t('Minimize apps')}
width="10" width="10"
height="10" height="10"
/> onClick={this._onMinimiseClick}
<b>{ this.formatAppTileName() }</b> /> }
{ this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName() && ( { this.props.showTitle && this._getTileTitle() }
<span>&nbsp;-&nbsp;{ this.state.widgetPageTitle }</span>
) }
</span> </span>
<span className="mx_AppTileMenuBarWidgets"> <span className="mx_AppTileMenuBarWidgets">
{ /* Snapshot widget */ }
{ showPictureSnapshotButton && <TintableSvgButton
src={showPictureSnapshotIcon}
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding"
title={_t('Picture')}
onClick={this._onSnapshotClick}
width="10"
height="10"
/> }
{ /* Edit widget */ } { /* Edit widget */ }
{ showEditButton && <TintableSvgButton { showEditButton && <TintableSvgButton
src="img/edit_green.svg" src="img/edit_green.svg"
className="mx_AppTileMenuBarWidget mx_AppTileMenuBarWidgetPadding" className={"mx_AppTileMenuBarWidget " +
(this.props.showDelete ? "mx_AppTileMenuBarWidgetPadding" : "")}
title={_t('Edit')} title={_t('Edit')}
onClick={this._onEditClick} onClick={this._onEditClick}
width="10" width="10"
@ -475,18 +621,71 @@ export default React.createClass({
/> } /> }
{ /* Delete widget */ } { /* Delete widget */ }
<TintableSvgButton { this.props.showDelete && <TintableSvgButton
src={deleteIcon} src={deleteIcon}
className={deleteClasses} className={deleteClasses}
title={_t(deleteWidgetLabel)} title={_t(deleteWidgetLabel)}
onClick={this._onDeleteClick} onClick={this._onDeleteClick}
width="10" width="10"
height="10" height="10"
/> /> }
</span> </span>
</div> </div> }
{ appTileBody } { appTileBody }
</div> </div>
); );
}, }
}); }
AppTile.displayName ='AppTile';
AppTile.propTypes = {
id: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
room: PropTypes.object.isRequired,
type: PropTypes.string.isRequired,
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool,
// UserId of the current user
userId: PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget
creatorUserId: PropTypes.string,
waitForIframeLoad: PropTypes.bool,
showMenubar: PropTypes.bool,
// Should the AppTile render itself
show: PropTypes.bool,
// Optional onEditClickHandler (overrides default behaviour)
onEditClick: PropTypes.func,
// Optional onDeleteClickHandler (overrides default behaviour)
onDeleteClick: PropTypes.func,
// Optional onMinimiseClickHandler
onMinimiseClick: PropTypes.func,
// Optionally hide the tile title
showTitle: PropTypes.bool,
// Optionally hide the tile minimise icon
showMinimise: PropTypes.bool,
// Optionally handle minimise button pointer events (default false)
handleMinimisePointerEvents: PropTypes.bool,
// Optionally hide the delete icon
showDelete: PropTypes.bool,
// Widget apabilities to allow by default (without user confirmation)
// NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events.
whitelistCapabilities: PropTypes.array,
// Optional function to be called on widget capability request
// Called with an array of the requested capabilities
onCapabilityRequest: PropTypes.func,
};
AppTile.defaultProps = {
url: "",
waitForIframeLoad: true,
showMenubar: true,
showTitle: true,
showMinimise: true,
showDelete: true,
handleMinimisePointerEvents: false,
whitelistCapabilities: [],
};

View file

@ -15,71 +15,30 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { DragSource, DropTarget } from 'react-dnd';
import TagTile from './TagTile'; import TagTile from './TagTile';
import dis from '../../../dispatcher';
import { findDOMNode } from 'react-dom';
const tagTileSource = { import { Draggable } from 'react-beautiful-dnd';
canDrag: function(props, monitor) {
return true;
},
beginDrag: function(props) { export default function DNDTagTile(props) {
// Return the data describing the dragged item return <div>
return { <Draggable
tag: props.groupProfile.groupId, key={props.tag}
}; draggableId={props.tag}
}, index={props.index}
type="draggable-TagTile"
endDrag: function(props, monitor, component) { >
const dropResult = monitor.getDropResult(); { (provided, snapshot) => (
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.groupProfile.groupId,
// 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.groupProfile.groupId,
};
},
};
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> <div>
<TagTile {...otherProps} /> <div
</div>, ref={provided.innerRef}
)); {...provided.draggableProps}
})); {...provided.dragHandleProps}
>
<TagTile {...props} />
</div>
{ provided.placeholder }
</div>
) }
</Draggable>
</div>;
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index'; import sdk from '../../../index';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
@ -24,8 +25,8 @@ export default React.createClass({
displayName: 'DeviceVerifyButtons', displayName: 'DeviceVerifyButtons',
propTypes: { propTypes: {
userId: React.PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
device: React.PropTypes.object.isRequired, device: PropTypes.object.isRequired,
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -0,0 +1,62 @@
/*
Copyright 2017 Aidan Gauland
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 PropTypes from "prop-types";
import { _t } from '../../../languageHandler';
/**
* Basic container for buttons in modal dialogs.
*/
module.exports = React.createClass({
displayName: "DialogButtons",
propTypes: {
// The primary button which is styled differently and has default focus.
primaryButton: PropTypes.node.isRequired,
// onClick handler for the primary button.
onPrimaryButtonClick: PropTypes.func.isRequired,
// onClick handler for the cancel button.
onCancel: PropTypes.func.isRequired,
focus: PropTypes.bool,
},
render: function() {
let primaryButtonClassName = "mx_Dialog_primary";
if (this.props.primaryButtonClass) {
primaryButtonClassName += " " + this.props.primaryButtonClass;
}
return (
<div className="mx_Dialog_buttons">
<button className={primaryButtonClassName}
onClick={this.props.onPrimaryButtonClick}
autoFocus={this.props.focus}
>
{ this.props.primaryButton }
</button>
{ this.props.children }
<button onClick={this.props.onCancel}>
{ _t("Cancel") }
</button>
</div>
);
},
});

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
export default class DirectorySearchBox extends React.Component { export default class DirectorySearchBox extends React.Component {
@ -105,10 +106,10 @@ export default class DirectorySearchBox extends React.Component {
} }
DirectorySearchBox.propTypes = { DirectorySearchBox.propTypes = {
className: React.PropTypes.string, className: PropTypes.string,
onChange: React.PropTypes.func, onChange: PropTypes.func,
onClear: React.PropTypes.func, onClear: PropTypes.func,
onJoinClick: React.PropTypes.func, onJoinClick: PropTypes.func,
placeholder: React.PropTypes.string, placeholder: PropTypes.string,
showJoinButton: React.PropTypes.bool, showJoinButton: PropTypes.bool,
}; };

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -56,14 +57,14 @@ class MenuOption extends React.Component {
} }
MenuOption.propTypes = { MenuOption.propTypes = {
children: React.PropTypes.oneOfType([ children: PropTypes.oneOfType([
React.PropTypes.arrayOf(React.PropTypes.node), PropTypes.arrayOf(React.PropTypes.node),
React.PropTypes.node, PropTypes.node,
]), ]),
highlighted: React.PropTypes.bool, highlighted: PropTypes.bool,
dropdownKey: React.PropTypes.string, dropdownKey: PropTypes.string,
onClick: React.PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
onMouseEnter: React.PropTypes.func.isRequired, onMouseEnter: PropTypes.func.isRequired,
}; };
/* /*
@ -322,20 +323,20 @@ Dropdown.propTypes = {
// The width that the dropdown should be. If specified, // The width that the dropdown should be. If specified,
// the dropped-down part of the menu will be set to this // the dropped-down part of the menu will be set to this
// width. // width.
menuWidth: React.PropTypes.number, menuWidth: PropTypes.number,
// Called when the selected option changes // Called when the selected option changes
onOptionChange: React.PropTypes.func.isRequired, onOptionChange: PropTypes.func.isRequired,
// Called when the value of the search field changes // Called when the value of the search field changes
onSearchChange: React.PropTypes.func, onSearchChange: PropTypes.func,
searchEnabled: React.PropTypes.bool, searchEnabled: PropTypes.bool,
// Function that, given the key of an option, returns // Function that, given the key of an option, returns
// a node representing that option to be displayed in the // a node representing that option to be displayed in the
// box itself as the currently-selected option (ie. as // box itself as the currently-selected option (ie. as
// opposed to in the actual dropped-down part). If // opposed to in the actual dropped-down part). If
// unspecified, the appropriate child element is used as // unspecified, the appropriate child element is used as
// in the dropped-down menu. // in the dropped-down menu.
getShortOption: React.PropTypes.func, getShortOption: PropTypes.func,
value: React.PropTypes.string, value: PropTypes.string,
// negative for consistency with HTML // negative for consistency with HTML
disabled: React.PropTypes.bool, disabled: PropTypes.bool,
}; };

View file

@ -17,6 +17,7 @@ limitations under the License.
'use strict'; 'use strict';
const React = require('react'); const React = require('react');
import PropTypes from 'prop-types';
const KEY_TAB = 9; const KEY_TAB = 9;
const KEY_SHIFT = 16; const KEY_SHIFT = 16;
@ -26,18 +27,18 @@ module.exports = React.createClass({
displayName: 'EditableText', displayName: 'EditableText',
propTypes: { propTypes: {
onValueChanged: React.PropTypes.func, onValueChanged: PropTypes.func,
initialValue: React.PropTypes.string, initialValue: PropTypes.string,
label: React.PropTypes.string, label: PropTypes.string,
placeholder: React.PropTypes.string, placeholder: PropTypes.string,
className: React.PropTypes.string, className: PropTypes.string,
labelClassName: React.PropTypes.string, labelClassName: PropTypes.string,
placeholderClassName: React.PropTypes.string, placeholderClassName: PropTypes.string,
// Overrides blurToSubmit if true // Overrides blurToSubmit if true
blurToCancel: React.PropTypes.bool, blurToCancel: PropTypes.bool,
// Will cause onValueChanged(value, true) to fire on blur // Will cause onValueChanged(value, true) to fire on blur
blurToSubmit: React.PropTypes.bool, blurToSubmit: PropTypes.bool,
editable: React.PropTypes.bool, editable: PropTypes.bool,
}, },
Phases: { Phases: {

View file

@ -15,6 +15,7 @@ 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 Promise from 'bluebird'; import Promise from 'bluebird';
@ -126,21 +127,21 @@ export default class EditableTextContainer extends React.Component {
EditableTextContainer.propTypes = { EditableTextContainer.propTypes = {
/* callback to retrieve the initial value. */ /* callback to retrieve the initial value. */
getInitialValue: React.PropTypes.func, getInitialValue: PropTypes.func,
/* initial value; used if getInitialValue is not given */ /* initial value; used if getInitialValue is not given */
initialValue: React.PropTypes.string, initialValue: PropTypes.string,
/* placeholder text to use when the value is empty (and not being /* placeholder text to use when the value is empty (and not being
* edited) */ * edited) */
placeholder: React.PropTypes.string, placeholder: PropTypes.string,
/* callback to update the value. Called with a single argument: the new /* callback to update the value. Called with a single argument: the new
* value. */ * value. */
onSubmit: React.PropTypes.func, onSubmit: PropTypes.func,
/* should the input submit when focus is lost? */ /* should the input submit when focus is lost? */
blurToSubmit: React.PropTypes.bool, blurToSubmit: PropTypes.bool,
}; };

View file

@ -16,6 +16,7 @@
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {emojifyText, containsEmoji} from '../../../HtmlUtils'; import {emojifyText, containsEmoji} from '../../../HtmlUtils';
export default function EmojiText(props) { export default function EmojiText(props) {
@ -32,8 +33,8 @@ export default function EmojiText(props) {
} }
EmojiText.propTypes = { EmojiText.propTypes = {
element: React.PropTypes.string, element: PropTypes.string,
children: React.PropTypes.string.isRequired, children: PropTypes.string.isRequired,
}; };
EmojiText.defaultProps = { EmojiText.defaultProps = {

View file

@ -63,7 +63,7 @@ FlairAvatar.propTypes = {
}; };
FlairAvatar.contextTypes = { FlairAvatar.contextTypes = {
matrixClient: React.PropTypes.instanceOf(MatrixClient).isRequired, matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
}; };
export default class Flair extends React.Component { export default class Flair extends React.Component {
@ -107,7 +107,11 @@ export default class Flair extends React.Component {
} }
const profiles = await this._getGroupProfiles(groups); const profiles = await this._getGroupProfiles(groups);
if (!this.unmounted) { if (!this.unmounted) {
this.setState({profiles: profiles.filter((profile) => {return profile.avatarUrl;})}); this.setState({
profiles: profiles.filter((profile) => {
return profile ? profile.avatarUrl : false;
}),
});
} }
} }
@ -134,5 +138,5 @@ Flair.propTypes = {
// this.context.matrixClient everywhere instead of this.props.matrixClient. // this.context.matrixClient everywhere instead of this.props.matrixClient.
// See https://github.com/vector-im/riot-web/issues/4951. // See https://github.com/vector-im/riot-web/issues/4951.
Flair.contextTypes = { Flair.contextTypes = {
matrixClient: React.PropTypes.instanceOf(MatrixClient).isRequired, matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
}; };

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