diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index f66d2c69d3..f501f373cd 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -8,7 +8,6 @@ src/CallHandler.js src/component-index.js src/components/structures/ContextualMenu.js src/components/structures/CreateRoom.js -src/components/structures/FilePanel.js src/components/structures/LoggedInView.js src/components/structures/login/ForgotPassword.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/UnknownDeviceDialog.js src/components/views/elements/AddressSelector.js -src/components/views/elements/CreateRoomButton.js src/components/views/elements/DeviceVerifyButtons.js src/components/views/elements/DirectorySearchBox.js src/components/views/elements/EditableText.js -src/components/views/elements/HomeButton.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/UserSelector.js src/components/views/login/CountryDropdown.js @@ -93,7 +86,6 @@ src/RichText.js src/Roles.js src/Rooms.js src/ScalarAuthClient.js -src/Tinter.js src/UiEffects.js src/Unread.js src/utils/DecryptFile.js diff --git a/.travis.yml b/.travis.yml index 4137d754bf..954f14a4da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,10 @@ dist: trusty # we don't need sudo, so can run in a container, which makes startup much # 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 node_js: diff --git a/CHANGELOG.md b/CHANGELOG.md index 87459882c9..a74351aa69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3) diff --git a/package.json b/package.json index 943c443c59..211fbb52c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.11.3", + "version": "0.12.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -56,7 +56,7 @@ "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", - "commonmark": "^0.27.0", + "commonmark": "^0.28.1", "counterpart": "^0.18.0", "draft-js": "^0.11.0-alpha", "draft-js-export-html": "^0.6.0", @@ -65,20 +65,20 @@ "file-saver": "^1.3.3", "filesize": "3.5.6", "flux": "2.1.1", + "focus-trap-react": "^3.0.5", "fuse.js": "^2.2.0", "glob": "^5.0.14", "highlight.js": "^8.9.1", "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "0.9.2", + "matrix-js-sdk": "0.10.0", "optimist": "^0.6.1", "prop-types": "^15.5.8", "querystring": "^0.2.0", "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", - "react-dnd": "^2.1.4", - "react-dnd-html5-backend": "^2.1.2", + "react-beautiful-dnd": "^4.0.0", "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.14.1", @@ -129,7 +129,7 @@ "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^1.17.3", - "source-map-loader": "^0.1.5", + "source-map-loader": "^0.2.3", "walk": "^2.3.9", "webpack": "^1.12.14" } diff --git a/src/Analytics.js b/src/Analytics.js index 1b4f45bc6b..2ef058b11b 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -14,25 +14,54 @@ limitations under the License. */ -import { getCurrentLanguage } from './languageHandler'; +import { getCurrentLanguage, _t, _td } from './languageHandler'; 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/"); +} function getRedactedUrl() { - const redactedHash = window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/"); // hardcoded url to make piwik happy - return 'https://riot.im/app/' + redactedHash; + return 'https://riot.im/app/' + getRedactedHash(); } const customVariables = { - 'App Platform': 1, - 'App Version': 2, - 'User Type': 3, - 'Chosen Language': 4, - 'Instance': 5, - 'RTE: Uses Richtext Mode': 6, - 'Homeserver URL': 7, - 'Identity Server URL': 8, + 'App Platform': { + id: 1, + expl: _td('The platform you\'re on'), + }, + 'App Version': { + id: 2, + expl: _td('The version of Riot.im'), + }, + '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) { @@ -40,9 +69,6 @@ function whitelistRedact(whitelist, str) { return ''; } -const whitelistedHSUrls = ["https://matrix.org"]; -const whitelistedISUrls = ["https://vector.im"]; - class Analytics { constructor() { this._paq = null; @@ -66,6 +92,10 @@ class Analytics { */ disable() { 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; } @@ -117,7 +147,10 @@ class Analytics { 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.firstPage) { // De-duplicate first page @@ -126,6 +159,7 @@ class Analytics { return; } this._paq.push(['setCustomUrl', getRedactedUrl()]); + this._paq.push(['setGenerationTimeMs', generationTimeMs]); this._paq.push(['trackPageView']); } @@ -140,11 +174,16 @@ class Analytics { } _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) { 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('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl)); @@ -154,6 +193,44 @@ class Analytics { if (this.disabled) return; 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:
+
+ { _t('The information being sent to us to help make Riot.im better includes:') } +
+ + { rows.map((row) => + + + ) } +
{ _t(customVariables[row[0]].expl) }{ row[1] }
+
+
+ { _t('We also record each page you use in the app (currently ), your User Agent' + + ' () and your device resolution ().', + {}, + { + CurrentPageHash: { getRedactedHash() }, + CurrentUserAgent: { navigator.userAgent }, + CurrentDeviceResolution: { resolution }, + }, + ) } + + { _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.') } +
+
, + }); + } } if (!global.mxAnalytics) { diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 8d40b65124..7fe625f8b9 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -275,6 +275,13 @@ class ContentMessages { 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) { const content = { body: file.name || 'Attachment', diff --git a/src/DateUtils.js b/src/DateUtils.js index 77f3644f6f..108697238c 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -15,7 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; import { _t } from './languageHandler'; function getDaysArray() { @@ -51,55 +50,89 @@ function pad(n) { return (n < 10 ? '0' : '') + n; } -function twelveHourTime(date) { +function twelveHourTime(date, showSeconds=false) { let hours = date.getHours() % 12; const minutes = pad(date.getMinutes()); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); hours = hours ? hours : 12; // convert 0 -> 12 + if (showSeconds) { + const seconds = pad(date.getSeconds()); + return `${hours}:${minutes}:${seconds}${ampm}`; + } return `${hours}:${minutes}${ampm}`; } -module.exports = { - formatDate: function(date, showTwelveHour=false) { - const now = new Date(); - const days = getDaysArray(); - const months = getMonthsArray(); - if (date.toDateString() === now.toDateString()) { - return this.formatTime(date, showTwelveHour); - } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { - // TODO: use standard date localize function provided in counterpart - return _t('%(weekDayName)s %(time)s', { - weekDayName: days[date.getDay()], - time: this.formatTime(date, showTwelveHour), - }); - } else if (now.getFullYear() === date.getFullYear()) { - // TODO: use standard date localize function provided in counterpart - return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { - weekDayName: days[date.getDay()], - monthName: months[date.getMonth()], - day: date.getDate(), - time: this.formatTime(date, showTwelveHour), - }); - } - return this.formatFullDate(date, showTwelveHour); - }, - - formatFullDate: function(date, showTwelveHour=false) { - const days = getDaysArray(); - const months = getMonthsArray(); - return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { +export function formatDate(date, showTwelveHour=false) { + const now = new Date(); + const days = getDaysArray(); + const months = getMonthsArray(); + if (date.toDateString() === now.toDateString()) { + return formatTime(date, showTwelveHour); + } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { + // TODO: use standard date localize function provided in counterpart + return _t('%(weekDayName)s %(time)s', { + weekDayName: days[date.getDay()], + time: formatTime(date, showTwelveHour), + }); + } else if (now.getFullYear() === date.getFullYear()) { + // TODO: use standard date localize function provided in counterpart + return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { weekDayName: days[date.getDay()], monthName: months[date.getMonth()], day: date.getDate(), - fullYear: date.getFullYear(), - time: this.formatTime(date, showTwelveHour), + time: formatTime(date, showTwelveHour), }); - }, + } + return formatFullDate(date, showTwelveHour); +} - formatTime: function(date, showTwelveHour=false) { - if (showTwelveHour) { - return twelveHourTime(date); - } - return pad(date.getHours()) + ':' + pad(date.getMinutes()); - }, -}; +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 months = getMonthsArray(); + return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { + weekDayName: days[date.getDay()], + monthName: months[date.getMonth()], + day: date.getDate(), + fullYear: date.getFullYear(), + time: formatFullTime(date, showTwelveHour), + }); +} + +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) { + return twelveHourTime(date); + } + 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(); +} diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js new file mode 100644 index 0000000000..ad1f1acbbd --- /dev/null +++ b/src/FromWidgetPostMessageApi.js @@ -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); + } +} diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index ef9010cbf2..c45a335ab6 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -22,28 +22,30 @@ import MatrixClientPeg from './MatrixClientPeg'; import GroupStoreCache from './stores/GroupStoreCache'; export function showGroupInviteDialog(groupId) { - const description =
-
{ _t("Who would you like to add to this community?") }
-
- { _t( - "Warning: any person you add to a community will be publicly "+ - "visible to anyone who knows the community ID", - ) } -
-
; + return new Promise((resolve, reject) => { + const description =
+
{ _t("Who would you like to add to this community?") }
+
+ { _t( + "Warning: any person you add to a community will be publicly "+ + "visible to anyone who knows the community ID", + ) } +
+
; - const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); - Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, { - title: _t("Invite new community members"), - description: description, - placeholder: _t("Name or matrix ID"), - button: _t("Invite to Community"), - validAddressTypes: ['mx-user-id'], - onFinished: (success, addrs) => { - if (!success) return; + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, { + title: _t("Invite new community members"), + description: description, + placeholder: _t("Name or matrix ID"), + button: _t("Invite to Community"), + validAddressTypes: ['mx-user-id'], + onFinished: (success, addrs) => { + if (!success) return; - _onGroupInviteFinished(groupId, addrs); - }, + _onGroupInviteFinished(groupId, addrs).then(resolve, reject); + }, + }); }); } @@ -87,7 +89,7 @@ function _onGroupInviteFinished(groupId, addrs) { const addrTexts = addrs.map((addr) => addr.address); - multiInviter.invite(addrTexts).then((completionStates) => { + return multiInviter.invite(addrTexts).then((completionStates) => { // Show user any errors const errorList = []; for (const addr of Object.keys(completionStates)) { diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 0c262fe89a..e3b7ba47f5 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,6 +25,7 @@ import escape from 'lodash/escape'; import emojione from 'emojione'; import classNames from 'classnames'; import MatrixClientPeg from './MatrixClientPeg'; +import url from 'url'; emojione.imagePathSVG = 'emojione/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 COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; +const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; + /* * Return true if the given string contains emoji * Uses a much, much simpler regex than emojione's so will give false @@ -152,6 +155,25 @@ export function sanitizedHtmlNode(insaneHtml) { return
; } +/** + * 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 = { allowedTags: [ '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 selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit - allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'], + allowedSchemes: PERMITTED_URL_SCHEMES, allowProtocolRelative: false, @@ -388,8 +410,7 @@ class TextHighlighter extends BaseHighlighter { * opts.disableBigEmoji: optional argument to disable the big emoji class. */ export function bodyToHtml(content, highlights, opts={}) { - const isHtml = (content.format === "org.matrix.custom.html"); - const body = isHtml ? content.formatted_body : escape(content.body); + let isHtml = (content.format === "org.matrix.custom.html"); let bodyHasEmoji = false; @@ -409,9 +430,27 @@ export function bodyToHtml(content, highlights, opts={}) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); }; } - safeBody = sanitizeHtml(body, sanitizeHtmlParams); - bodyHasEmoji = containsEmoji(body); - if (bodyHasEmoji) safeBody = unicodeToImage(safeBody); + + bodyHasEmoji = containsEmoji(isHtml ? content.formatted_body : content.body); + + // 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 { delete sanitizeHtmlParams.textFilter; } @@ -429,7 +468,10 @@ export function bodyToHtml(content, highlights, opts={}) { 'mx_EventTile_bigEmoji': emojiBody, 'markdown-body': isHtml, }); - return ; + + return isHtml ? + : + { content.body }; } export function emojifyText(text) { diff --git a/src/IntegrationManager.js b/src/IntegrationManager.js new file mode 100644 index 0000000000..eb45a1f425 --- /dev/null +++ b/src/IntegrationManager.js @@ -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"); + } +} diff --git a/src/Keyboard.js b/src/Keyboard.js index 9c872e1c66..bf83a1a05f 100644 --- a/src/Keyboard.js +++ b/src/Keyboard.js @@ -68,3 +68,12 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) { 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; + } +} diff --git a/src/Lifecycle.js b/src/Lifecycle.js index efd5c20d5c..ec1fca2bc6 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -362,7 +362,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); }); - startMatrixClient(); + await startMatrixClient(); return MatrixClientPeg.get(); } @@ -423,7 +423,7 @@ export function logout() { * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. */ -function startMatrixClient() { +async function startMatrixClient() { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -437,7 +437,7 @@ function startMatrixClient() { Presence.start(); DMRoomMap.makeShared().start(); - MatrixClientPeg.start(); + await MatrixClientPeg.start(); // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. diff --git a/src/Markdown.js b/src/Markdown.js index e05f163ba5..aa1c7e45b1 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -55,25 +55,6 @@ function is_multi_line(node) { 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 * a given message actually uses any markdown syntax or whether @@ -81,7 +62,7 @@ function linkifyMarkdown(s) { */ export default class Markdown { constructor(input) { - this.input = linkifyMarkdown(input); + this.input = input; const parser = new commonmark.Parser(); this.parsed = parser.parse(this.input); diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 6e3a380396..9d86a62de4 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -174,4 +174,4 @@ class MatrixClientPeg { if (!global.mxMatrixClientPeg) { global.mxMatrixClientPeg = new MatrixClientPeg(); } -module.exports = global.mxMatrixClientPeg; +export default global.mxMatrixClientPeg; diff --git a/src/Modal.js b/src/Modal.js index 68d75d1ff1..2565d5c73b 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,8 +19,10 @@ limitations under the License. const React = require('react'); const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; import Analytics from './Analytics'; import sdk from './index'; +import dis from './dispatcher'; 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 * with the real component once it loads. */ - loader: React.PropTypes.func.isRequired, + loader: PropTypes.func.isRequired, }, getInitialState: function() { @@ -187,10 +189,22 @@ class ModalManager { _reRender() { 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()); 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 dialog = (
diff --git a/src/Notifier.js b/src/Notifier.js index 75b698862c..b823c4df05 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -135,6 +135,10 @@ const Notifier = { const plaf = PlatformPeg.get(); 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); // 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 // disabled again in the future, we will show the banner again. - this.setToolbarHidden(false); + this.setToolbarHidden(true); } else { dis.dispatch({ action: "notifier_enabled", @@ -252,6 +256,10 @@ const Notifier = { }, 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()); if (idx === -1) return; diff --git a/src/Presence.js b/src/Presence.js index fab518e1cb..9367fe35cd 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -31,7 +32,7 @@ class Presence { this.running = true; if (undefined === this.state) { this._resetTimer(); - this.dispatcherRef = dis.register(this._onUserActivity.bind(this)); + this.dispatcherRef = dis.register(this._onAction.bind(this)); } } @@ -95,8 +96,10 @@ class Presence { this.setState("unavailable"); } - _onUserActivity() { - this._resetTimer(); + _onAction(payload) { + if (payload.action === "user_activity") { + this._resetTimer(); + } } /** diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 1979c6d111..31541148d9 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -85,9 +85,7 @@ function _onStartChatFinished(shouldInvite, addrs) { if (rooms.length > 0) { // A Direct Message room already exists for this user, so select a // room from a list that is similar to the one in MemberInfo panel - const ChatCreateOrReuseDialog = sdk.getComponent( - "views.dialogs.ChatCreateOrReuseDialog", - ); + const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog"); const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, { userId: addrTexts[0], 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 { // Start multi user chat let room; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 5cc078dc59..91e49fe09b 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -34,7 +34,14 @@ export function getRoomNotifsState(roomId) { } // 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 // (in particular this will be 'wrong' for one to one rooms because @@ -130,6 +137,11 @@ function setRoomNotifsStateUnmuted(roomId, newState) { } 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) { if (isRuleForRoom(roomId, rule)) { if (isMuteRule(rule) && rule.enabled) { diff --git a/src/Rooms.js b/src/Rooms.js index 6cc2d867a6..ffa39141ff 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -43,7 +43,7 @@ export function getOnlyOtherMember(room, me) { return null; } -export function isConfCallRoom(room, me, conferenceHandler) { +function _isConfCallRoom(room, me, conferenceHandler) { if (!conferenceHandler) return false; if (me.membership != "join") { @@ -58,6 +58,26 @@ export function isConfCallRoom(room, me, conferenceHandler) { if (conferenceHandler.isConferenceUser(otherMember.userId)) { 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) { diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 7bd8603264..c7e439bf2e 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -39,11 +39,53 @@ class ScalarAuthClient { // Returns a scalar_token string getScalarToken() { - const tok = window.localStorage.getItem("mx_scalar_token"); - if (tok) return Promise.resolve(tok); + const token = window.localStorage.getItem("mx_scalar_token"); - // No saved token, so do the dance to get one. First, we - // need an openid bearer token from the HS. + if (!token) { + 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) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(token_object); @@ -106,10 +148,48 @@ class ScalarAuthClient { 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; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); + url += "&room_name=" + encodeURIComponent(roomName); url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme")); if (id) { url += '&integ_id=' + encodeURIComponent(id); diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 3c164c6551..123d02159e 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -235,6 +235,7 @@ const SdkConfig = require('./SdkConfig'); const MatrixClientPeg = require("./MatrixClientPeg"); const MatrixEvent = require("matrix-js-sdk").MatrixEvent; const dis = require("./dispatcher"); +const Widgets = require('./utils/widgets'); import { _t } from './languageHandler'; function sendResponse(event, res) { @@ -291,6 +292,7 @@ function setWidget(event, roomId) { const widgetUrl = event.data.url; const widgetName = event.data.name; // optional const widgetData = event.data.data; // optional + const userWidget = event.data.userWidget; const client = MatrixClientPeg.get(); if (!client) { @@ -330,17 +332,54 @@ function setWidget(event, roomId) { name: widgetName, data: widgetData, }; - if (widgetUrl === null) { // widget is being deleted - content = {}; - } - client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { - sendResponse(event, { - success: true, + 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" }); }); - }, (err) => { - sendError(event, _t('Failed to send request.'), err); - }); + } else { // Room widget + if (!roomId) { + sendError(event, _t('Missing roomId.'), null); + } + + if (widgetUrl === null) { // widget is being deleted + content = {}; + } + // TODO - Room widgets need to be moved to 'm.widget' state events + // https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing + client.sendStateEvent(roomId, "im.vector.modular.widgets", content, widgetId).done(() => { + sendResponse(event, { + success: true, + }); + }, (err) => { + sendError(event, _t('Failed to send request.'), err); + }); + } } function getWidgets(event, roomId) { @@ -349,19 +388,30 @@ function getWidgets(event, roomId) { sendError(event, _t('You need to be logged in.')); return; } - const room = client.getRoom(roomId); - if (!room) { - sendError(event, _t('This room is not recognised.')); - return; - } - const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); - // Only return widgets which have required fields - const widgetStateEvents = []; - stateEvents.forEach((ev) => { - if (ev.getContent().type && ev.getContent().url) { - widgetStateEvents.push(ev.event); // return the raw event + let widgetStateEvents = []; + + if (roomId) { + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t('This room is not recognised.')); + return; } - }); + // TODO - Room widgets need to be moved to 'm.widget' state events + // https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing + const stateEvents = room.currentState.getStateEvents("im.vector.modular.widgets"); + // Only return widgets which have required fields + if (room) { + stateEvents.forEach((ev) => { + if (ev.getContent().type && ev.getContent().url) { + widgetStateEvents.push(ev.event); // return the raw event + } + }); + } + } + + // Add user widgets (not linked to a specific room) + const userWidgets = Widgets.getUserWidgetsArray(); + widgetStateEvents = widgetStateEvents.concat(userWidgets); sendResponse(event, widgetStateEvents); } @@ -563,7 +613,7 @@ const onMessage = function(event) { const url = SdkConfig.get().integrations_ui_url; if ( event.origin.length === 0 || - !url.startsWith(event.origin) || + !url.startsWith(event.origin + '/') || !event.data.action || event.data.api // Ignore messages with specific API set ) { @@ -578,9 +628,22 @@ const onMessage = function(event) { const roomId = event.data.room_id; const userId = event.data.user_id; + if (!roomId) { - sendError(event, _t('Missing room_id in request')); - return; + // 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')); + return; + } } let promise = Promise.resolve(currentRoomId); if (!currentRoomId) { @@ -601,6 +664,15 @@ const onMessage = function(event) { return; } + // Get and set room-based widgets + if (event.data.action === "get_widgets") { + getWidgets(event, roomId); + return; + } else if (event.data.action === "set_widget") { + setWidget(event, roomId); + return; + } + // These APIs don't require userId if (event.data.action === "join_rules_state") { getJoinRules(event, roomId); @@ -611,12 +683,6 @@ const onMessage = function(event) { } else if (event.data.action === "get_membership_count") { getMembershipCount(event, roomId); 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") { getRoomEncState(event, roomId); return; diff --git a/src/SdkConfig.js b/src/SdkConfig.js index 8df725a913..64bf21ecf8 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -21,6 +21,13 @@ const DEFAULTS = { integrations_rest_url: "https://scalar.vector.im/api", // Where to send bug reports. If not specified, bugs cannot be sent. 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 { @@ -45,3 +52,4 @@ class SdkConfig { } module.exports = SdkConfig; +module.exports.DEFAULTS = DEFAULTS; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 344bac1ddb..d45e45e84c 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -96,6 +96,8 @@ const commands = { colorScheme.primary_color = matches[1]; if (matches[4]) { colorScheme.secondary_color = matches[4]; + } else { + colorScheme.secondary_color = colorScheme.primary_color; } return success( SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), @@ -295,7 +297,7 @@ const commands = { // Define the power level of a user op: new Command("op", " []", function(roomId, args) { if (args) { - const matches = args.match(/^(\S+?)( +(\d+))?$/); + const matches = args.match(/^(\S+?)( +(-?\d+))?$/); let powerLevel = 50; // default power level for op if (matches) { const userId = matches[1]; diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 1bdf5ad90c..e60bde4094 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -52,8 +52,7 @@ function textForMemberEvent(ev) { case 'join': if (prevContent && prevContent.membership === 'join') { if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { - return _t('%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.', { - senderName, + return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', { oldDisplayName: prevContent.displayname, displayName: content.displayname, }); diff --git a/src/Tinter.js b/src/Tinter.js index c7402c15be..d24a4c3e74 100644 --- a/src/Tinter.js +++ b/src/Tinter.js @@ -252,7 +252,6 @@ class Tinter { setTheme(theme) { - console.trace("setTheme " + theme); this.theme = theme; // update keyRgb from the current theme CSS itself, if it defines it @@ -299,56 +298,66 @@ class Tinter { for (let i = 0; i < document.styleSheets.length; i++) { const ss = document.styleSheets[i]; - if (!ss) continue; // well done safari >:( - // Chromium apparently sometimes returns null here; unsure why. - // see $14534907369972FRXBx:matrix.org in HQ - // ...ah, it's because there's a third party extension like - // privacybadger inserting its own stylesheet in there with a - // resource:// URI or something which results in a XSS error. - // See also #vector:matrix.org/$145357669685386ebCfr:matrix.org - // ...except some browsers apparently return stylesheets without - // hrefs, which we have no choice but ignore right now + try { + if (!ss) continue; // well done safari >:( + // Chromium apparently sometimes returns null here; unsure why. + // see $14534907369972FRXBx:matrix.org in HQ + // ...ah, it's because there's a third party extension like + // privacybadger inserting its own stylesheet in there with a + // resource:// URI or something which results in a XSS error. + // See also #vector:matrix.org/$145357669685386ebCfr:matrix.org + // ...except some browsers apparently return stylesheets without + // hrefs, which we have no choice but ignore right now - // XXX seriously? we are hardcoding the name of vector's CSS file in - // here? - // - // Why do we need to limit it to vector's CSS file anyway - if there - // are other CSS files affecting the doc don't we want to apply the - // same transformations to them? - // - // Iterating through the CSS looking for matches to hack on feels - // pretty horrible anyway. And what if the application skin doesn't use - // Vector Green as its primary color? - // --richvdh + // XXX seriously? we are hardcoding the name of vector's CSS file in + // here? + // + // Why do we need to limit it to vector's CSS file anyway - if there + // are other CSS files affecting the doc don't we want to apply the + // same transformations to them? + // + // Iterating through the CSS looking for matches to hack on feels + // pretty horrible anyway. And what if the application skin doesn't use + // Vector Green as its primary color? + // --richvdh - // Yes, tinting assumes that you are using the Riot skin for now. - // The right solution will be to move the CSS over to react-sdk. - // And yes, the default assets for the base skin might as well use - // Vector Green as any other colour. - // --matthew + // Yes, tinting assumes that you are using the Riot skin for now. + // The right solution will be to move the CSS over to react-sdk. + // And yes, the default assets for the base skin might as well use + // Vector Green as any other colour. + // --matthew - if (ss.href && !ss.href.match(new RegExp('/theme-' + this.theme + '.css$'))) continue; - if (ss.disabled) continue; - if (!ss.cssRules) continue; + // 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.cssRules) continue; - if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href); + if (DEBUG) console.debug("calcCssFixups checking " + ss.cssRules.length + " rules for " + ss.href); - for (let j = 0; j < ss.cssRules.length; j++) { - const rule = ss.cssRules[j]; - if (!rule.style) continue; - if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue; - for (let k = 0; k < this.cssAttrs.length; k++) { - const attr = this.cssAttrs[k]; - for (let l = 0; l < this.keyRgb.length; l++) { - if (rule.style[attr] === this.keyRgb[l]) { - this.cssFixups[this.theme].push({ - style: rule.style, - attr: attr, - index: l, - }); + for (let j = 0; j < ss.cssRules.length; j++) { + const rule = ss.cssRules[j]; + if (!rule.style) continue; + if (rule.selectorText && rule.selectorText.match(/#mx_theme/)) continue; + for (let k = 0; k < this.cssAttrs.length; k++) { + const attr = this.cssAttrs[k]; + for (let l = 0; l < this.keyRgb.length; l++) { + if (rule.style[attr] === this.keyRgb[l]) { + this.cssFixups[this.theme].push({ + style: rule.style, + attr: attr, + index: l, + }); + } } } } + } 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) { diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js new file mode 100644 index 0000000000..ccaa0207c1 --- /dev/null +++ b/src/ToWidgetPostMessageApi.js @@ -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); + } + }); + } +} diff --git a/src/Unread.js b/src/Unread.js index 383b5c2e5a..55e60f2a9a 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -28,6 +28,8 @@ module.exports = { return false; } else if (ev.getType() == 'm.room.member') { 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') { return false; } else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { diff --git a/src/Velociraptor.js b/src/Velociraptor.js index 9a674d4f09..af4e6dcb60 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -1,5 +1,6 @@ const React = require('react'); const ReactDom = require('react-dom'); +import PropTypes from 'prop-types'; const Velocity = require('velocity-vector'); /** @@ -14,16 +15,16 @@ module.exports = React.createClass({ propTypes: { // either a list of child nodes, or a single child. - children: React.PropTypes.any, + children: PropTypes.any, // 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 - startStyles: React.PropTypes.array, + startStyles: PropTypes.array, // a list of transition options from the corresponding startStyle - enterTransitionOpts: React.PropTypes.array, + enterTransitionOpts: PropTypes.array, }, getDefaultProps: function() { diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 0f23413b5f..effd96dacf 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -15,312 +15,91 @@ limitations under the License. */ /* -Listens for incoming postMessage requests from embedded widgets. The following API is exposed: -{ - api: "widget", - action: "content_loaded", - widgetId: $WIDGET_ID, - data: {} - // additional request fields -} - -The complete request object is returned to the caller with an additional "response" key like so: -{ - api: "widget", - action: "content_loaded", - widgetId: $WIDGET_ID, - data: {}, - // additional request fields - response: { ... } -} - -The "api" field is required to use this API, and must be set to "widget" in all requests. - -The "action" determines the format of the request and response. All actions can return an error response. - -Additional data can be sent as additional, abritrary fields. However, typically the data object should be used. - -A success response is an object with zero or more keys. - -An error response is a "response" object which consists of a sole "error" key to indicate an error. -They look like: -{ - error: { - message: "Unable to invite user into room.", - _error: - } -} -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", -} - +* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for +* spec. details / documentation. */ -import URL from 'url'; +import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; +import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; -const WIDGET_API_VERSION = '0.0.1'; // Current API version -const SUPPORTED_WIDGET_API_VERSIONS = [ - '0.0.1', -]; - -import dis from './dispatcher'; - -if (!global.mxWidgetMessagingListenerCount) { - global.mxWidgetMessagingListenerCount = 0; +if (!global.mxFromWidgetMessaging) { + global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); + global.mxFromWidgetMessaging.start(); } -if (!global.mxWidgetMessagingMessageEndpoints) { - global.mxWidgetMessagingMessageEndpoints = []; +if (!global.mxToWidgetMessaging) { + global.mxToWidgetMessaging = new ToWidgetPostMessageApi(); + global.mxToWidgetMessaging.start(); } +const OUTBOUND_API_NAME = 'toWidget'; -/** - * Register widget message event listeners - */ -function startListening() { - if (global.mxWidgetMessagingListenerCount === 0) { - window.addEventListener("message", onMessage, false); - } - global.mxWidgetMessagingListenerCount += 1; -} - -/** - * De-register widget message event listeners - */ -function stopListening() { - global.mxWidgetMessagingListenerCount -= 1; - if (global.mxWidgetMessagingListenerCount === 0) { - window.removeEventListener("message", onMessage); - } - if (global.mxWidgetMessagingListenerCount < 0) { - // Make an error so we get a stack trace - const e = new Error( - "WidgetMessaging: mismatched startListening / stopListening detected." + - " Negative count", - ); - console.error(e); - } -} - -/** - * Register a widget endpoint for trusted postMessage communication - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - */ -function addEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn("Invalid origin:", endpointUrl); - return; - } - - const origin = u.protocol + '//' + u.host; - const endpoint = new WidgetMessageEndpoint(widgetId, origin); - if (global.mxWidgetMessagingMessageEndpoints) { - if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) { - return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); - })) { - // Message endpoint already registered - console.warn("Endpoint already registered"); - return; - } - global.mxWidgetMessagingMessageEndpoints.push(endpoint); - } -} - -/** - * De-register a widget endpoint from trusted communication sources - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - * @return {boolean} True if endpoint was successfully removed - */ -function removeEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn("Invalid origin"); - return; - } - - const origin = u.protocol + '//' + u.host; - if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) { - const length = global.mxWidgetMessagingMessageEndpoints.length; - global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints.filter(function(endpoint) { - return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin); - }); - return (length > global.mxWidgetMessagingMessageEndpoints.length); - } - return false; -} - - -/** - * Handle widget postMessage events - * @param {Event} event Event to handle - * @return {undefined} - */ -function onMessage(event) { - if (!event.origin) { // Handle chrome - event.origin = event.originalEvent.origin; - } - - // Event origin is empty string if undefined - if ( - event.origin.length === 0 || - !trustedEndpoint(event.origin) || - event.data.api !== "widget" || - !event.data.widgetId - ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise - } - - const action = event.data.action; - const widgetId = event.data.widgetId; - if (action === 'content_loaded') { - dis.dispatch({ - action: 'widget_content_loaded', - widgetId: widgetId, - }); - sendResponse(event, {success: true}); - } else if (action === 'supported_api_versions') { - sendResponse(event, { - api: "widget", - supported_versions: SUPPORTED_WIDGET_API_VERSIONS, - }); - } else if (action === 'api_version') { - sendResponse(event, { - api: "widget", - version: WIDGET_API_VERSION, - }); - } else { - console.warn("Widget postMessage event unhandled"); - sendError(event, {message: "The postMessage was unhandled"}); - } -} - -/** - * Check if message origin is registered as trusted - * @param {string} origin PostMessage origin to check - * @return {boolean} True if trusted - */ -function trustedEndpoint(origin) { - if (!origin) { - return false; - } - - return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => { - return endpoint.endpointUrl === origin; - }); -} - -/** - * Send a postmessage response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {Object} res Response data - */ -function sendResponse(event, res) { - const data = JSON.parse(JSON.stringify(event.data)); - data.response = res; - event.source.postMessage(data, event.origin); -} - -/** - * Send an error response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {string} msg Error message - * @param {Error} nestedError Nested error event (optional) - */ -function sendError(event, msg, nestedError) { - console.error("Action:" + event.data.action + " failed with message: " + msg); - const data = JSON.parse(JSON.stringify(event.data)); - data.response = { - error: { - message: msg, - }, - }; - if (nestedError) { - data.response.error._error = nestedError; - } - event.source.postMessage(data, event.origin); -} - -/** - * Represents mapping of widget instance to URLs for trusted postMessage communication. - */ -class WidgetMessageEndpoint { - /** - * Mapping of widget instance to URL for trusted postMessage communication. - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin. - */ - constructor(widgetId, endpointUrl) { - if (!widgetId) { - throw new Error("No widgetId specified in widgetMessageEndpoint constructor"); - } - if (!endpointUrl) { - throw new Error("No endpoint specified in widgetMessageEndpoint constructor"); - } +export default class WidgetMessaging { + constructor(widgetId, widgetUrl, target) { this.widgetId = widgetId; - this.endpointUrl = endpointUrl; + this.widgetUrl = widgetUrl; + this.target = target; + this.fromWidget = global.mxFromWidgetMessaging; + this.toWidget = global.mxToWidgetMessaging; + this.start(); + } + + messageToWidget(action) { + return this.toWidget.exec(action, this.target).then((data) => { + // Check for errors and reject if found + if (data.response === undefined) { // null is valid + 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); } } - -export default { - startListening: startListening, - stopListening: stopListening, - addEndpoint: addEndpoint, - removeEndpoint: removeEndpoint, -}; diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js new file mode 100644 index 0000000000..9114e12137 --- /dev/null +++ b/src/WidgetMessagingEndpoint.js @@ -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; + } +} diff --git a/src/WidgetUtils.js b/src/WidgetUtils.js index 34c998978d..5f45a8c58c 100644 --- a/src/WidgetUtils.js +++ b/src/WidgetUtils.js @@ -17,8 +17,8 @@ limitations under the License. import MatrixClientPeg from './MatrixClientPeg'; export default class WidgetUtils { - /* Returns true if user is able to send state events to modify widgets in this room + * (Does not apply to non-room-based / user widgets) * @param roomId -- The ID of the room to check * @return Boolean -- true if the user can modify widgets in this room * @throws Error -- specifies the error reason diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js index 33bdb53799..6e1d52a88f 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.js @@ -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 * the given MatrixClient. @@ -78,6 +199,11 @@ export default { start(matrixClient) { this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); 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) { const listener = (...args) => { - dis.dispatch(actionCreator(matrixClient, ...args)); + dis.dispatch(actionCreator(matrixClient, ...args), true); }; matrixClient.on(eventName, listener); this._matrixClientListenersStop.push(() => { diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js new file mode 100644 index 0000000000..e5911c4e32 --- /dev/null +++ b/src/actions/RoomListActions.js @@ -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; diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js index 60946ea7f1..a257ff16d8 100644 --- a/src/actions/TagOrderActions.js +++ b/src/actions/TagOrderActions.js @@ -22,25 +22,87 @@ const TagOrderActions = {}; /** * Creates an action thunk that will do an asynchronous request to - * commit TagOrderStore.getOrderedTags() to account data and dispatch - * actions to indicate the status of the request. + * move a tag in TagOrderStore to destinationIx. * * @param {MatrixClient} matrixClient the matrix client to set the * 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 * indicating the status of the request. * @see asyncAction */ -TagOrderActions.commitTagOrdering = function(matrixClient) { - return asyncAction('TagOrderActions.commitTagOrdering', () => { - // Only commit tags if the state is ready, i.e. not null - const tags = TagOrderStore.getOrderedTags(); - if (!tags) { - return; - } +TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) { + // Only commit tags if the state is ready, i.e. not null + let tags = TagOrderStore.getOrderedTags(); + let removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + if (!tags) { + 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'); - 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}; }); }; diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js index bddfbc7c63..967ce609e7 100644 --- a/src/actions/actionCreators.js +++ b/src/actions/actionCreators.js @@ -22,16 +22,32 @@ limitations under the License. * suffix determining whether it is pending, successful or * a failure. * @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 * argument as a dispatch function to dispatch the * following actions: * `${id}.pending` and either * `${id}.success` or * `${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) => { - dispatch({action: id + '.pending'}); + dispatch({ + action: id + '.pending', + request: + typeof pendingFn === 'function' ? pendingFn() : undefined, + }); fn().then((result) => { dispatch({action: id + '.success', result}); }).catch((err) => { diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index a8f588d39a..5db8b2365f 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ const React = require("react"); +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; const sdk = require('../../../index'); const MatrixClientPeg = require("../../../MatrixClientPeg"); @@ -23,8 +24,8 @@ module.exports = React.createClass({ displayName: 'EncryptedEventDialog', propTypes: { - event: React.PropTypes.object.isRequired, - onFinished: React.PropTypes.func.isRequired, + event: PropTypes.object.isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 04274442c2..06fb0668d5 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -16,6 +16,7 @@ limitations under the License. import FileSaver from 'file-saver'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as Matrix from 'matrix-js-sdk'; @@ -29,8 +30,8 @@ export default React.createClass({ displayName: 'ExportE2eKeysDialog', propTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, - onFinished: React.PropTypes.func.isRequired, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index a01b6580f1..10744a8911 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import * as Matrix from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; @@ -40,8 +41,8 @@ export default React.createClass({ displayName: 'ImportE2eKeysDialog', propTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, - onFinished: React.PropTypes.func.isRequired, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index d47f1a161a..e33fa7861f 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -105,6 +105,11 @@ const COMMANDS = [ args: '', description: _td('Stops ignoring a user, showing their messages going forward'), }, + { + command: '/devtools', + args: '', + description: _td('Opens the Developer Tools dialog'), + }, // Omitting `/markdown` as it only seems to apply to OldComposer ]; diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js index a27533f7c2..b09f4e963e 100644 --- a/src/autocomplete/Components.js +++ b/src/autocomplete/Components.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; /* These were earlier stateless functional components but had to be converted @@ -42,10 +43,10 @@ export class TextualCompletion extends React.Component { } } TextualCompletion.propTypes = { - title: React.PropTypes.string, - subtitle: React.PropTypes.string, - description: React.PropTypes.string, - className: React.PropTypes.string, + title: PropTypes.string, + subtitle: PropTypes.string, + description: PropTypes.string, + className: PropTypes.string, }; export class PillCompletion extends React.Component { @@ -69,9 +70,9 @@ export class PillCompletion extends React.Component { } } PillCompletion.propTypes = { - title: React.PropTypes.string, - subtitle: React.PropTypes.string, - description: React.PropTypes.string, - initialComponent: React.PropTypes.element, - className: React.PropTypes.string, + title: PropTypes.string, + subtitle: PropTypes.string, + description: PropTypes.string, + initialComponent: PropTypes.element, + className: PropTypes.string, }; diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 1e1928a1ee..31599703c2 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -25,6 +25,7 @@ import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; import _sortBy from 'lodash/sortBy'; +import {makeRoomPermalink} from "../matrix-to"; const ROOM_REGEX = /(?=#)(\S*)/g; @@ -78,7 +79,7 @@ export default class RoomProvider extends AutocompleteProvider { return { completion: displayAlias, suffix: ' ', - href: 'https://matrix.to/#/' + displayAlias, + href: makeRoomPermalink(displayAlias), component: ( } title={room.name} description={displayAlias} /> ), diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 794f507d21..e636f95751 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -28,6 +28,7 @@ import _sortBy from 'lodash/sortBy'; import MatrixClientPeg from '../MatrixClientPeg'; import type {Room, RoomMember} from 'matrix-js-sdk'; +import {makeUserPermalink} from "../matrix-to"; const USER_REGEX = /@\S*/g; @@ -43,6 +44,7 @@ export default class UserProvider extends AutocompleteProvider { this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], shouldMatchPrefix: true, + shouldMatchWordsOnly: false }); 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. if (toStartOfTimeline || !data || !data.liveEvent) return; + // TODO: lazyload if we have no ev.sender room member? 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. completion: user.rawDisplayName.replace(' (IRC)', ''), suffix: range.start === 0 ? ': ' : ' ', - href: 'https://matrix.to/#/' + user.userId, + href: makeUserPermalink(user.userId), component: ( } @@ -146,6 +149,7 @@ export default class UserProvider extends AutocompleteProvider { onUserSpoke(user: RoomMember) { if (this.users === null) return; + if (!user) return; if (user.userId === MatrixClientPeg.get().credentials.userId) return; // 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 { - return
+ return
{ completions }
; } diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index 3c2308e6a7..0e2df890f3 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -20,6 +20,7 @@ limitations under the License. const classNames = require('classnames'); const React = require('react'); const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -29,11 +30,21 @@ module.exports = { ContextualMenuContainerId: "mx_ContextualMenu_Container", propTypes: { - menuWidth: React.PropTypes.number, - menuHeight: React.PropTypes.number, - chevronOffset: React.PropTypes.number, - menuColour: React.PropTypes.string, - chevronFace: React.PropTypes.string, // top, bottom, left, right + top: PropTypes.number, + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + 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() { @@ -51,14 +62,19 @@ module.exports = { createMenu: function(Element, props) { const self = this; - const closeMenu = function() { + const closeMenu = function(...args) { ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); 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 = {}; let chevronFace = null; @@ -129,13 +145,26 @@ module.exports = { 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 // property set here so you can't close the menu from a button click! const menu = (
{ chevron } - +
diff --git a/src/components/structures/CreateRoom.js b/src/components/structures/CreateRoom.js index 26454c5ea6..2bb9adb544 100644 --- a/src/components/structures/CreateRoom.js +++ b/src/components/structures/CreateRoom.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; @@ -30,8 +31,8 @@ module.exports = React.createClass({ displayName: 'CreateRoom', propTypes: { - onRoomCreated: React.PropTypes.func, - collapsedRhs: React.PropTypes.bool, + onRoomCreated: PropTypes.func, + collapsedRhs: PropTypes.bool, }, phases: { diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index ffa5e45249..3249cae22c 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import sdk from '../../index'; @@ -28,7 +29,7 @@ const FilePanel = React.createClass({ displayName: 'FilePanel', propTypes: { - roomId: React.PropTypes.string.isRequired, + roomId: PropTypes.string.isRequired, }, getInitialState: function() { @@ -67,6 +68,9 @@ const FilePanel = React.createClass({ "room": { "timeline": { "contains_url": true, + "not_types": [ + "m.sticker", + ], }, }, }, diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 5ffb97c6ed..62fdb1070a 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -1,6 +1,6 @@ /* Copyright 2017 Vector Creations Ltd. -Copyright 2017 New Vector Ltd. +Copyright 2017, 2018 New Vector Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -29,8 +29,9 @@ import classnames from 'classnames'; import GroupStoreCache from '../../stores/GroupStoreCache'; import GroupStore from '../../stores/GroupStore'; +import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; -import GeminiScrollbar from 'react-gemini-scrollbar'; +import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -209,7 +210,7 @@ const FeaturedRoom = React.createClass({ let permalink = null; 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; @@ -366,7 +367,7 @@ const FeaturedUser = React.createClass({ const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); 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 = { name }; const httpUrl = MatrixClientPeg.get() .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); @@ -390,7 +391,7 @@ const FeaturedUser = React.createClass({ }); const GroupContext = { - groupStore: React.PropTypes.instanceOf(GroupStore).isRequired, + groupStore: PropTypes.instanceOf(GroupStore).isRequired, }; CategoryRoomList.contextTypes = GroupContext; @@ -398,6 +399,9 @@ FeaturedRoom.contextTypes = GroupContext; RoleUserList.contextTypes = GroupContext; FeaturedUser.contextTypes = GroupContext; +const GROUP_JOINPOLICY_OPEN = "open"; +const GROUP_JOINPOLICY_INVITE = "invite"; + export default React.createClass({ displayName: 'GroupView', @@ -408,7 +412,7 @@ export default React.createClass({ }, childContextTypes: { - groupStore: React.PropTypes.instanceOf(GroupStore), + groupStore: PropTypes.instanceOf(GroupStore), }, getChildContext: function() { @@ -428,6 +432,7 @@ export default React.createClass({ editing: false, saving: false, uploadingAvatar: false, + avatarChanged: false, membershipBusy: false, publicityBusy: false, inviterProfile: null, @@ -461,6 +466,10 @@ export default React.createClass({ _onGroupMyMembership: function(group) { 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}); }, @@ -543,6 +552,12 @@ export default React.createClass({ this.setState({ editing: true, profileForm: Object.assign({}, this.state.summary.profile), + joinableForm: { + policyType: + this.state.summary.profile.is_openly_joinable ? + GROUP_JOINPOLICY_OPEN : + GROUP_JOINPOLICY_INVITE, + }, }); dis.dispatch({ action: 'panel_disable', @@ -551,6 +566,10 @@ export default React.createClass({ }, _onCancelClick: function() { + this._closeSettings(); + }, + + _closeSettings() { this.setState({ editing: false, profileForm: null, @@ -589,6 +608,10 @@ export default React.createClass({ this.setState({ uploadingAvatar: false, profileForm: newProfileForm, + + // Indicate that FlairStore needs to be poked to show this change + // in TagTile (TagPanel), Flair and GroupTile (MyGroups). + avatarChanged: true, }); }).catch((e) => { this.setState({uploadingAvatar: false}); @@ -601,11 +624,15 @@ export default React.createClass({ }).done(); }, + _onJoinableChange: function(ev) { + this.setState({ + joinableForm: { policyType: ev.target.value }, + }); + }, + _onSaveClick: function() { this.setState({saving: true}); - const savePromise = this.state.isUserPrivileged ? - this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm) : - Promise.resolve(); + const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve(); savePromise.then((result) => { this.setState({ saving: false, @@ -614,6 +641,11 @@ export default React.createClass({ }); dis.dispatch({action: 'panel_disable'}); 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) => { this.setState({ saving: false, @@ -624,11 +656,27 @@ export default React.createClass({ title: _t('Error'), description: _t('Failed to update community'), }); + }).finally(() => { + this.setState({ + avatarChanged: false, + }); }).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}); + + // 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(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { @@ -641,9 +689,14 @@ export default React.createClass({ }); }, - _onRejectInviteClick: function() { + _onRejectInviteClick: async function() { 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 }).catch((e) => { 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() { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Leave Group', '', QuestionDialog, { @@ -662,18 +734,23 @@ export default React.createClass({ description: _t("Leave %(groupName)s?", {groupName: this.props.groupId}), button: _t("Leave"), danger: true, - onFinished: (confirmed) => { + onFinished: async (confirmed) => { if (!confirmed) return; 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 }).catch((e) => { this.setState({membershipBusy: false}); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Error leaving room', '', ErrorDialog, { + Modal.createTrackedDialog('Error leaving community', '', ErrorDialog, { 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 ?

{ _t('Community Settings') }

:
; + const changeDelayWarning = this.state.editing && this.state.isUserPrivileged ? +
+ { _t( + 'Changes made to your community name and avatar ' + + 'might not be seen by other users for up to 30 minutes.', + {}, + { + 'bold1': (sub) => { sub } , + 'bold2': (sub) => { sub } , + }, + ) } +
:
; return
{ header } + { changeDelayWarning } + { this._getJoinableNode() } { this._getLongDescriptionNode() } { this._getRoomsNode() }
; @@ -831,9 +922,8 @@ export default React.createClass({ const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); 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) { return
@@ -874,33 +964,107 @@ export default React.createClass({
; - } 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 a member of this community"); - const leaveButtonClasses = classnames({ - "mx_RoomHeader_textButton": true, - "mx_GroupView_textButton": true, - "mx_GroupView_leaveButton": true, - "mx_RoomHeader_textButton_danger": this.state.isUserPrivileged, - }); - return
-
- { /* Empty div for flex alignment */ } -
-
- - { _t("Leave") } - -
-
-
; + + membershipButtonExtraClasses = { + 'mx_GroupView_leaveButton': true, + 'mx_RoomHeader_textButton_danger': this.state.isUserPrivileged, + }; + membershipContainerExtraClasses = 'mx_GroupView_membershipSection_joined'; + } else { + return null; } - return null; + + const membershipButtonClasses = classnames([ + 'mx_RoomHeader_textButton', + 'mx_GroupView_textButton', + ], + membershipButtonExtraClasses, + ); + + const membershipContainerClasses = classnames( + 'mx_GroupView_membershipSection', + membershipContainerExtraClasses, + ); + + return
+
+ { /* The
is for flex alignment */ } + { this.state.membershipBusy ? :
} +
+ + { membershipButtonText } + +
+
+
; + }, + + _getJoinableNode: function() { + return this.state.editing ?
+

+ { _t('Who can join this community?') } + { this.state.groupJoinableLoading ? + :
+ } +

+
+ +
+
+ +
+
: null; }, _getLongDescriptionNode: function() { @@ -946,6 +1110,7 @@ export default React.createClass({ const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const Spinner = sdk.getComponent("elements.Spinner"); const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); if (this.state.summaryLoading && this.state.error === null || this.state.saving) { return ; @@ -1096,9 +1261,9 @@ export default React.createClass({ { rightButtons }
- + { bodyNodes } - +
); } else if (this.state.error) { diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 8a2c1b8c79..8428e3c714 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -18,6 +18,7 @@ import Matrix from 'matrix-js-sdk'; const InteractiveAuth = Matrix.InteractiveAuth; import React from 'react'; +import PropTypes from 'prop-types'; import {getEntryComponentForLoginType} from '../views/login/InteractiveAuthEntryComponents'; @@ -26,18 +27,18 @@ export default React.createClass({ propTypes: { // 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 // mount. - authData: React.PropTypes.shape({ - flows: React.PropTypes.array, - params: React.PropTypes.object, - session: React.PropTypes.string, + authData: PropTypes.shape({ + flows: PropTypes.array, + params: PropTypes.object, + session: PropTypes.string, }), // callback - makeRequest: React.PropTypes.func.isRequired, + makeRequest: PropTypes.func.isRequired, // callback called when the auth process has finished, // successfully or unsuccessfully. @@ -51,22 +52,22 @@ export default React.createClass({ // the auth session. // * clientSecret {string} The client secret used in auth // sessions with the ID server. - onAuthFinished: React.PropTypes.func.isRequired, + onAuthFinished: PropTypes.func.isRequired, // Inputs provided by the user to the auth process // and used by various stages. As passed to js-sdk // interactive-auth - inputs: React.PropTypes.object, + inputs: PropTypes.object, // As js-sdk interactive-auth - makeRegistrationUrl: React.PropTypes.func, - sessionId: React.PropTypes.string, - clientSecret: React.PropTypes.string, - emailSid: React.PropTypes.string, + makeRegistrationUrl: PropTypes.func, + sessionId: PropTypes.string, + clientSecret: PropTypes.string, + emailSid: PropTypes.string, // If true, poll to see if the auth flow has been completed // out-of-band - poll: React.PropTypes.bool, + poll: PropTypes.bool, }, getInitialState: function() { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 38b7634edb..d9ac9de693 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -18,8 +18,8 @@ limitations under the License. import * as Matrix from 'matrix-js-sdk'; import React from 'react'; -import { DragDropContext } from 'react-dnd'; -import HTML5Backend from 'react-dnd-html5-backend'; +import PropTypes from 'prop-types'; +import { DragDropContext } from 'react-beautiful-dnd'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import Notifier from '../../Notifier'; @@ -31,6 +31,9 @@ import sessionStore from '../../stores/SessionStore'; import MatrixClientPeg from '../../MatrixClientPeg'; 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 * determined by the page_type property. @@ -44,23 +47,23 @@ const LoggedInView = React.createClass({ displayName: 'LoggedInView', propTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, - page_type: React.PropTypes.string.isRequired, - onRoomCreated: React.PropTypes.func, - onUserSettingsClose: React.PropTypes.func, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + page_type: PropTypes.string.isRequired, + onRoomCreated: PropTypes.func, + onUserSettingsClose: PropTypes.func, // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) - onRegistered: React.PropTypes.func, + onRegistered: PropTypes.func, - teamToken: React.PropTypes.string, + teamToken: PropTypes.string, // and lots and lots of other stuff. }, childContextTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient), - authCache: React.PropTypes.object, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient), + authCache: PropTypes.object, }, 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() { - const TagPanel = sdk.getComponent('structures.TagPanel'); const LeftPanel = sdk.getComponent('structures.LeftPanel'); const RightPanel = sdk.getComponent('structures.RightPanel'); const RoomView = sdk.getComponent('structures.RoomView'); @@ -328,23 +374,23 @@ const LoggedInView = React.createClass({ } return ( -
+
{ topBar } -
- { SettingsStore.isFeatureEnabled("feature_tag_panel") ? :
} - -
- { page_element } -
- { right_panel } -
+ +
+ +
+ { page_element } +
+ { right_panel } +
+
); }, }); -export default DragDropContext(HTML5Backend)(LoggedInView); +export default LoggedInView; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 3452d13841..92baecb787 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -19,6 +19,7 @@ limitations under the License. import Promise from 'bluebird'; import React from 'react'; +import PropTypes from 'prop-types'; import Matrix from "matrix-js-sdk"; import Analytics from "../../Analytics"; @@ -92,38 +93,38 @@ export default React.createClass({ displayName: 'MatrixChat', propTypes: { - config: React.PropTypes.object, - ConferenceHandler: React.PropTypes.any, - onNewScreen: React.PropTypes.func, - registrationUrl: React.PropTypes.string, - enableGuest: React.PropTypes.bool, + config: PropTypes.object, + ConferenceHandler: PropTypes.any, + onNewScreen: PropTypes.func, + registrationUrl: PropTypes.string, + enableGuest: PropTypes.bool, // 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 - startingFragmentQueryParams: React.PropTypes.object, + startingFragmentQueryParams: PropTypes.object, // 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 // window.location - initialScreenAfterLogin: React.PropTypes.shape({ - screen: React.PropTypes.string.isRequired, - params: React.PropTypes.object, + initialScreenAfterLogin: PropTypes.shape({ + screen: PropTypes.string.isRequired, + params: PropTypes.object, }), // displayname, if any, to set on the device when logging // in/registering. - defaultDeviceDisplayName: React.PropTypes.string, + defaultDeviceDisplayName: PropTypes.string, // A function that makes a registration URL - makeRegistrationUrl: React.PropTypes.func.isRequired, + makeRegistrationUrl: PropTypes.func.isRequired, }, childContextTypes: { - appConfig: React.PropTypes.object, + appConfig: PropTypes.object, }, AuxPanel: { @@ -170,6 +171,10 @@ export default React.createClass({ register_hs_url: null, register_is_url: 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; }, @@ -286,6 +291,8 @@ export default React.createClass({ this.handleResize(); window.addEventListener('resize', this.handleResize); + this._pageChanging = false; + // check we have the right tint applied for this theme. // N.B. we don't call the whole of setTheme() here as we may be // racing with the theme CSS download finishing from index.js @@ -363,13 +370,58 @@ export default React.createClass({ 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) { dis.dispatch({action: 'focus_composer'}); 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) { if (state.view === undefined) { throw new Error("setStateForNewView with no view!"); @@ -607,6 +659,16 @@ export default React.createClass({ case 'send_event': this.onSendEvent(payload.room_id, payload.event); 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) { - this.setStateForNewView({ + const newState = { 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 - // user just clicked 'register' - register_client_secret: params.client_secret, - register_session_id: params.session_id, - register_hs_url: params.hs_url, - register_is_url: params.is_url, - register_id_sid: params.sid, - }); + }; + + // Only honour params if they are all present, otherwise we reset + // HS and IS URLs when switching to registration. + if (params.client_secret && + params.session_id && + params.hs_url && + params.is_url && + 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'); }, @@ -846,16 +916,36 @@ export default React.createClass({ }).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(( + + { _t("This room is not public. You will not be able to rejoin without an invite.") } + + )); + } + } + return warnings; + }, + _leaveRoom: function(roomId) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - const roomToLeave = MatrixClientPeg.get().getRoom(roomId); + const warnings = this._leaveRoomWarnings(roomId); + Modal.createTrackedDialog('Leave room', '', QuestionDialog, { title: _t("Leave room"), description: ( { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } + { warnings } ), 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 // the timeline panel. If the timeline panel doesn't exist, then we assume // it is safe to reset the timeline. - if (!self._loggedInView) { + if (!self._loggedInView || !self._loggedInView.child) { return true; } - return self._loggedInView.getDecoratedComponentInstance().canResetTimelineInRoom(roomId); + return self._loggedInView.child.canResetTimelineInRoom(roomId); }); cli.on('sync', function(state, prevState) { @@ -1142,18 +1232,6 @@ export default React.createClass({ cli.on("crypto.warning", (type) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); switch (type) { - case 'CRYPTO_WARNING_ACCOUNT_MIGRATED': - Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, { - title: _t('Cryptography data migrated'), - description: _t( - "A one-off migration of cryptography data has been performed. "+ - "End-to-end encryption will not work if you go back to an older "+ - "version of Riot. If you need to use end-to-end cryptography on "+ - "an older version, log out of Riot first. To retain message history, "+ - "export and re-import your keys.", - ), - }); - break; case 'CRYPTO_WARNING_OLD_VERSION_DETECTED': Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, { title: _t('Old cryptography data detected'), @@ -1310,7 +1388,6 @@ export default React.createClass({ if (this.props.onNewScreen) { this.props.onNewScreen(screen); } - Analytics.trackPageChange(); }, 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) { if (this.props.startingFragmentQueryParams.referrer) { params.referrer = this.props.startingFragmentQueryParams.referrer; @@ -1568,6 +1656,7 @@ export default React.createClass({ onLoginClick={this.onLoginClick} onRegisterClick={this.onRegisterClick} onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null} + onServerConfigChange={this.onServerConfigChange} /> ); } @@ -1602,6 +1691,7 @@ export default React.createClass({ onForgotPasswordClick={this.onForgotPasswordClick} enableGuest={this.props.enableGuest} onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null} + onServerConfigChange={this.onServerConfigChange} /> ); } diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 53cc660a9b..50bdb37734 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -16,15 +16,15 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; import shouldHideEvent from '../../shouldHideEvent'; +import {wantsDateSeparator} from '../../DateUtils'; import dis from "../../dispatcher"; import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; -const MILLIS_IN_DAY = 86400000; - /* (almost) stateless UI component which builds the event tiles in the room timeline. */ module.exports = React.createClass({ @@ -32,63 +32,63 @@ module.exports = React.createClass({ propTypes: { // 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 // back-pagination in progress - backPaginating: React.PropTypes.bool, + backPaginating: PropTypes.bool, // true to show a spinner at the end of the timeline to indicate // forward-pagination in progress - forwardPaginating: React.PropTypes.bool, + forwardPaginating: PropTypes.bool, // 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. - highlightedEventId: React.PropTypes.string, + highlightedEventId: PropTypes.string, // Should we show URL Previews - showUrlPreview: React.PropTypes.bool, + showUrlPreview: PropTypes.bool, // event after which we should show a read marker - readMarkerEventId: React.PropTypes.string, + readMarkerEventId: PropTypes.string, // 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 // for pending messages. - ourUserId: React.PropTypes.string, + ourUserId: PropTypes.string, // true to suppress the date at the start of the timeline - suppressFirstDateSeparator: React.PropTypes.bool, + suppressFirstDateSeparator: PropTypes.bool, // 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 // scroll down when we are at the bottom of the window. See ScrollPanel // for more details. - stickyBottom: React.PropTypes.bool, + stickyBottom: PropTypes.bool, // 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. - onFillRequest: React.PropTypes.func, + onFillRequest: PropTypes.func, // className for the panel - className: React.PropTypes.string.isRequired, + className: PropTypes.string.isRequired, // shape parameter to be passed to EventTiles - tileShape: React.PropTypes.string, + tileShape: PropTypes.string, // show twelve hour timestamps - isTwelveHour: React.PropTypes.bool, + isTwelveHour: PropTypes.bool, // show timestamps always - alwaysShowTimestamps: React.PropTypes.bool, + alwaysShowTimestamps: PropTypes.bool, }, componentWillMount: function() { @@ -325,7 +325,7 @@ module.exports = React.createClass({ const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial"); if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) { - const dateSeparator =
  • ; + const dateSeparator =
  • ; ret.push(dateSeparator); } @@ -447,10 +447,18 @@ module.exports = React.createClass({ // is this a continuation of the previous message? 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 && prevEvent.sender && mxEv.sender && mxEv.sender.userId === prevEvent.sender.userId - && mxEv.getType() == prevEvent.getType()) { + && (mxEv.getType() == prevEvent.getType() || eventTypeContinues)) { continuation = true; } @@ -479,7 +487,7 @@ module.exports = React.createClass({ // do we need a date separator since the last event? if (this._wantsDateSeparator(prevEvent, eventDate)) { - const dateSeparator =
  • ; + const dateSeparator =
  • ; ret.push(dateSeparator); continuation = false; } @@ -522,17 +530,7 @@ module.exports = React.createClass({ // here. return !this.props.suppressFirstDateSeparator; } - const prevEventDate = prevEvent.getDate(); - 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(); + return wantsDateSeparator(prevEvent.getDate(), nextEventDate); }, // get a list of read receipts that should be shown next to this event diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 9281fb199e..7a93cfb886 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import GeminiScrollbar from 'react-gemini-scrollbar'; +import PropTypes from 'prop-types'; import sdk from '../../index'; import { _t } from '../../languageHandler'; import dis from '../../dispatcher'; @@ -26,7 +26,7 @@ export default withMatrixClient(React.createClass({ displayName: 'MyGroups', propTypes: { - matrixClient: React.PropTypes.object.isRequired, + matrixClient: PropTypes.object.isRequired, }, getInitialState: function() { @@ -62,6 +62,8 @@ export default withMatrixClient(React.createClass({ const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); const TintableSvg = sdk.getComponent("elements.TintableSvg"); const GroupTile = sdk.getComponent("groups.GroupTile"); + const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); + let content; let contentHeader; @@ -72,9 +74,26 @@ export default withMatrixClient(React.createClass({ }); contentHeader = groupNodes.length > 0 ?

    { _t('Your Communities') }

    :
    ; content = groupNodes.length > 0 ? - - { groupNodes } - : + +
    +

    + { _t( + "Did you know: you can use communities to filter your Riot.im experience!", + ) } +

    +

    + { _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.", + ) } +

    +
    +
    + { groupNodes } +
    +
    :
    { _t( "You're not currently a member of any communities.", diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 77d506d9af..8034923158 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import { _t } from '../../languageHandler'; import sdk from '../../index'; @@ -23,7 +24,7 @@ import WhoIsTyping from '../../WhoIsTyping'; import MatrixClientPeg from '../../MatrixClientPeg'; import MemberAvatar from '../views/avatars/MemberAvatar'; import Resend from '../../Resend'; -import { showUnknownDeviceDialogForMessages } from '../../cryptodevices'; +import * as cryptodevices from '../../cryptodevices'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -41,59 +42,59 @@ module.exports = React.createClass({ propTypes: { // 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 - numUnreadMessages: React.PropTypes.number, + numUnreadMessages: PropTypes.number, // this is true if we are fully scrolled-down, and are looking at // 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. // 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 // the 'Active Call' text in the status bar if there is nothing // more interesting) - hasActiveCall: React.PropTypes.bool, + hasActiveCall: PropTypes.bool, // Number of names to display in typing indication. E.g. set to 3, will // 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 // 'unsent messages' bar - onResendAllClick: React.PropTypes.func, + onResendAllClick: PropTypes.func, // callback for when the user clicks on the 'cancel all' button in the // 'unsent messages' bar - onCancelAllClick: React.PropTypes.func, + onCancelAllClick: PropTypes.func, // callback for when the user clicks on the 'invite others' button in the // '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 // 'you are alone' bar - onStopWarningClick: React.PropTypes.func, + onStopWarningClick: PropTypes.func, // 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 // status bar. This is used to trigger a re-layout in the parent // component. - onResize: React.PropTypes.func, + onResize: PropTypes.func, // callback for when the status bar can be hidden from view, as it is // not displaying anything - onHidden: React.PropTypes.func, + onHidden: PropTypes.func, // callback for when the status bar is displaying something and should // be visible - onVisible: React.PropTypes.func, + onVisible: PropTypes.func, }, 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() { Resend.resendUnsentEvents(this.props.room); }, @@ -156,7 +164,7 @@ module.exports = React.createClass({ }, _onShowDevicesClick: function() { - showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room); + cryptodevices.showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room); }, _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 _checkSize: function() { - if (this.props.onVisible && this._getSize()) { - this.props.onVisible(); + if (this._getSize()) { + 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) { title = _t("Message not sent due to unknown devices being present"); content = _t( - "Show devices or cancel all.", + "Show devices, send anyway or cancel.", {}, { 'showDevicesText': (sub) => { sub }, + 'sendAnywayText': (sub) => { sub }, 'cancelText': (sub) => { sub }, }, ); @@ -302,11 +313,11 @@ module.exports = React.createClass({ ) { title = unsentMessages[0].error.data.error; } 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("Resend all or cancel all now. " + + content = _t("%(count)s Resend all or cancel all now. " + "You can also select individual messages to resend or cancel.", - {}, + { count: unsentMessages.length }, { 'resendText': (sub) => { sub }, diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e240ab38d5..6fc16b9760 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -24,6 +24,7 @@ import shouldHideEvent from "../../shouldHideEvent"; const React = require("react"); const ReactDOM = require("react-dom"); +import PropTypes from 'prop-types'; import Promise from 'bluebird'; const classNames = require("classnames"); import { _t } from '../../languageHandler'; @@ -58,18 +59,18 @@ if (DEBUG) { module.exports = React.createClass({ displayName: 'RoomView', propTypes: { - ConferenceHandler: React.PropTypes.any, + ConferenceHandler: PropTypes.any, // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) - onRegistered: React.PropTypes.func, + onRegistered: PropTypes.func, // An object representing a third party invite to join this room // Fields: // * inviteSignUrl (string) The URL used to join this room from an email invite // (given as part of the link in the invite email) // * 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 // 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 // * inviterName (string) The display name of the person who // * invited us tovthe room - oobData: React.PropTypes.object, + oobData: PropTypes.object, // is the RightPanel collapsed? - collapsedRhs: React.PropTypes.bool, + collapsedRhs: PropTypes.bool, }, getInitialState: function() { @@ -263,12 +264,19 @@ module.exports = React.createClass({ isPeeking: true, // this will change to false if peeking fails }); MatrixClientPeg.get().peekInRoom(roomId).then((room) => { + if (this.unmounted) { + return; + } this.setState({ room: room, peekLoading: false, }); this._onRoomLoaded(room); }, (err) => { + if (this.unmounted) { + return; + } + // Stop peeking if anything went wrong this.setState({ isPeeking: false, @@ -285,7 +293,7 @@ module.exports = React.createClass({ } else { throw err; } - }).done(); + }); } } else if (room) { // Stop peeking because we have joined this room previously @@ -459,6 +467,15 @@ module.exports = React.createClass({ case 'message_sent': this._checkIfAlone(this.state.room); 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 'upload_failed': case 'upload_started': @@ -619,8 +636,8 @@ module.exports = React.createClass({ const room = this.state.room; if (!room) return; - const color_scheme = SettingsStore.getValue("roomColor", room.room_id); console.log("Tinter.tint from updateTint"); + const color_scheme = SettingsStore.getValue("roomColor", room.roomId); 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 // refresh the conf call notification state this._updateConfCallNotification(); - - // 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(); - } - } + this._updateDMState(); }, 500), _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() { dis.dispatch({ action: 'timeline_resize' }, true); }, @@ -818,18 +857,6 @@ module.exports = React.createClass({ action: 'join_room', 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(); }); }, @@ -889,7 +916,7 @@ module.exports = React.createClass({ ContentMessages.sendContentToRoom( file, this.state.room.roomId, MatrixClientPeg.get(), - ).done(undefined, (error) => { + ).catch((error) => { if (error.name === "UnknownDeviceError") { // Let the staus bar handle this return; @@ -898,11 +925,27 @@ module.exports = React.createClass({ console.error("Failed to upload file " + file + " " + error); Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, { 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) { this.setState({ searchTerm: term, @@ -1347,10 +1390,12 @@ module.exports = React.createClass({ }, 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({ statusBarVisible: false, - }); + });*/ }, showSettings: function(show) { @@ -1583,7 +1628,8 @@ module.exports = React.createClass({ displayConfCallNotification={this.state.displayConfCallNotification} maxHeight={this.state.auxPanelMaxHeight} onResize={this.onChildResize} - showApps={this.state.showApps && !this.state.editingRoomSettings} > + showApps={this.state.showApps} + hideAppsDrawer={this.state.editingRoomSettings} > { aux } ); diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 37cb2977aa..0fdbc9a349 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -16,9 +16,10 @@ limitations under the License. const React = require("react"); const ReactDOM = require("react-dom"); -const GeminiScrollbar = require('react-gemini-scrollbar'); +import PropTypes from 'prop-types'; import Promise from 'bluebird'; import { KeyCode } from '../../Keyboard'; +import sdk from '../../index.js'; const DEBUG_SCROLL = false; // var DEBUG_SCROLL = true; @@ -86,7 +87,7 @@ module.exports = React.createClass({ * scroll down to show the new element, rather than preserving the * existing view. */ - stickyBottom: React.PropTypes.bool, + stickyBottom: PropTypes.bool, /* startAtBottom: if set to true, the view is assumed to start * scrolled to the bottom. @@ -95,7 +96,7 @@ module.exports = React.createClass({ * behaviour stays the same for other uses of ScrollPanel. * 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 * 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 * the user scrolls again. */ - onFillRequest: React.PropTypes.func, + onFillRequest: PropTypes.func, /* onUnfillRequest(backwards): a callback which is called on scroll when * 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 * 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: React.PropTypes.func, + onScroll: PropTypes.func, /* onResize: a callback which is called whenever the Gemini scroll * panel is resized */ - onResize: React.PropTypes.func, + onResize: PropTypes.func, /* 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: React.PropTypes.object, + style: PropTypes.object, }, getDefaultProps: function() { @@ -223,7 +224,7 @@ module.exports = React.createClass({ onResize: function() { this.props.onResize(); 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 @@ -664,14 +665,25 @@ module.exports = React.createClass({ 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() { + const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); // 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. // it's not obvious why we have a separate div and ol anyway. - return (
    @@ -679,7 +691,7 @@ module.exports = React.createClass({ { this.props.children }
    -
    + ); }, }); diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 49d22d8e52..0b6dc9fc75 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -17,15 +17,15 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; -import FilterStore from '../../stores/FilterStore'; -import FlairStore from '../../stores/FlairStore'; import TagOrderStore from '../../stores/TagOrderStore'; import GroupActions from '../../actions/GroupActions'; -import TagOrderActions from '../../actions/TagOrderActions'; import sdk from '../../index'; import dis from '../../dispatcher'; +import { _t } from '../../languageHandler'; + +import { Droppable } from 'react-beautiful-dnd'; const TagPanel = React.createClass({ displayName: 'TagPanel', @@ -36,17 +36,7 @@ const TagPanel = React.createClass({ getInitialState() { return { - // A list of group profiles for tags that are group IDs. The intention in future - // 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...', - // }, - ], + orderedTags: [], selectedTags: [], }; }, @@ -54,28 +44,15 @@ const TagPanel = React.createClass({ componentWillMount: function() { this.unmounted = false; 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(() => { if (this.unmounted) { return; } - - const orderedTags = TagOrderStore.getOrderedTags() || []; - const orderedGroupTags = orderedTags.filter((t) => t[0] === '+'); - // 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.setState({ + orderedTags: TagOrderStore.getOrderedTags() || [], + selectedTags: TagOrderStore.getSelectedTags(), }); }); // This could be done by anything with a matrix client @@ -85,6 +62,7 @@ const TagPanel = React.createClass({ componentWillUnmount() { this.unmounted = true; this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); + this.context.matrixClient.removeListener("sync", this._onClientSync); if (this._filterStoreToken) { this._filterStoreToken.remove(); } @@ -95,7 +73,17 @@ const TagPanel = React.createClass({ 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'}); }, @@ -104,30 +92,65 @@ const TagPanel = React.createClass({ dis.dispatch({action: 'view_create_group'}); }, - onTagTileEndDrag() { - dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient)); + onClearFilterClick(ev) { + dis.dispatch({action: 'deselect_tags'}); }, render() { + const GroupsButton = sdk.getComponent('elements.GroupsButton'); + const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); 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 ; }); - return
    -
    - { tags } -
    - - + + const clearButton = this.state.selectedTags.length > 0 ? + : +
    ; + + return
    + + { clearButton } +
    + + + { (provided, snapshot) => ( +
    + { tags } + { provided.placeholder } +
    + ) } +
    +
    +
    +
    + +
    ; }, }); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 98f57a60b5..1a03b5d994 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -19,6 +19,7 @@ import SettingsStore from "../../settings/SettingsStore"; const React = require('react'); const ReactDOM = require("react-dom"); +import PropTypes from 'prop-types'; import Promise from 'bluebird'; 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 // a timeline representing. If it has a room, we maintain RRs etc for // 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. - manageReadReceipts: React.PropTypes.bool, - manageReadMarkers: React.PropTypes.bool, + manageReadReceipts: PropTypes.bool, + manageReadMarkers: PropTypes.bool, // 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. // 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 // live timeline. - eventId: React.PropTypes.string, + eventId: PropTypes.string, // 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 // half way down the viewport. - eventPixelOffset: React.PropTypes.number, + eventPixelOffset: PropTypes.number, // Should we show URL Previews - showUrlPreview: React.PropTypes.bool, + showUrlPreview: PropTypes.bool, // 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. - onReadMarkerUpdated: React.PropTypes.func, + onReadMarkerUpdated: PropTypes.func, // maximum number of events to show in a timeline - timelineCap: React.PropTypes.number, + timelineCap: PropTypes.number, // classname to use for the messagepanel - className: React.PropTypes.string, + className: PropTypes.string, // shape property to be passed to EventTiles - tileShape: React.PropTypes.string, + tileShape: PropTypes.string, // placeholder text to use if the timeline is empty - empty: React.PropTypes.string, + empty: PropTypes.string, }, statics: { @@ -301,6 +302,8 @@ var TimelinePanel = React.createClass({ // set off a pagination request. onMessageListFillRequest: function(backwards) { + if (!this._shouldPaginate()) return Promise.resolve(false); + const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate'; const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating'; @@ -621,6 +624,7 @@ var TimelinePanel = React.createClass({ this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); dis.dispatch({ action: 'on_room_read', + roomId: this.props.timelineSet.room.roomId, }); } } @@ -1090,6 +1094,17 @@ var TimelinePanel = React.createClass({ }, 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() { const MessagePanel = sdk.getComponent("structures.MessagePanel"); const Loader = sdk.getComponent("elements.Spinner"); @@ -1107,9 +1122,9 @@ var TimelinePanel = React.createClass({ // exist. if (this.state.timelineLoading) { return ( -
    - -
    +
    + +
    ); } diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index ca566d3a64..fed4ff33b3 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -15,6 +15,7 @@ limitations under the License. */ const React = require('react'); +import PropTypes from 'prop-types'; const ContentMessages = require('../../ContentMessages'); const dis = require('../../dispatcher'); const filesize = require('filesize'); @@ -22,7 +23,7 @@ import { _t } from '../../languageHandler'; module.exports = React.createClass({displayName: 'UploadBar', propTypes: { - room: React.PropTypes.object, + room: PropTypes.object, }, componentDidMount: function() { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 09844c3d63..85223c4eef 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -19,6 +19,7 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; const React = require('react'); const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; const sdk = require('../../index'); const MatrixClientPeg = require("../../MatrixClientPeg"); const PlatformPeg = require("../../PlatformPeg"); @@ -29,7 +30,6 @@ import Promise from 'bluebird'; const packageJson = require('../../../package.json'); const UserSettingsStore = require('../../UserSettingsStore'); const CallMediaHandler = require('../../CallMediaHandler'); -const GeminiScrollbar = require('react-gemini-scrollbar'); const Email = require('../../email'); const AddThreepid = require('../../AddThreepid'); const SdkConfig = require('../../SdkConfig'); @@ -78,6 +78,7 @@ const SIMPLE_SETTINGS = [ { id: "Pill.shouldHidePillAvatar" }, { id: "TextualBody.disableBigEmoji" }, { id: "VideoView.flipVideoHorizontally" }, + { id: "TagPanel.disableTagPanel" }, ]; // These settings must be defined in SettingsStore @@ -125,8 +126,8 @@ const THEMES = [ const IgnoredUser = React.createClass({ propTypes: { - userId: React.PropTypes.string.isRequired, - onUnignored: React.PropTypes.func.isRequired, + userId: PropTypes.string.isRequired, + onUnignored: PropTypes.func.isRequired, }, _onUnignoreClick: function() { @@ -155,16 +156,16 @@ module.exports = React.createClass({ displayName: 'UserSettings', propTypes: { - onClose: React.PropTypes.func, + onClose: PropTypes.func, // 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. - referralBaseUrl: React.PropTypes.string, + referralBaseUrl: PropTypes.string, // Team token for the referral link. If falsy, the referral section will // not appear - teamToken: React.PropTypes.string, + teamToken: PropTypes.string, }, getDefaultProps: function() { @@ -375,7 +376,7 @@ module.exports = React.createClass({ { _t("For security, logging out will delete any end-to-end " + "encryption keys from this browser. If you want to be able " + "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.") }
    , button: _t("Sign out"), extraButtons: [ @@ -793,11 +794,18 @@ module.exports = React.createClass({ } return (
    -

    { _t("Bug Report") }

    +

    { _t("Debug Logs Submission") }

    -

    { _t("Found a bug?") }

    +

    { + _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.", + ) + }

    @@ -811,6 +819,12 @@ module.exports = React.createClass({

    { _t('Analytics') }

    { _t('Riot collects anonymous analytics to allow us to improve the application.') } +
    + { _t('Privacy is important to us, so we don\'t collect any personal' + + ' or identifiable data for our analytics.') } +
    + { _t('Learn more about how we use analytics.') } +
    { ANALYTICS_SETTINGS.map( this._renderDeviceSetting ) }
    ; @@ -1103,6 +1117,7 @@ module.exports = React.createClass({ const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); const Notifications = sdk.getComponent("settings.Notifications"); const EditableText = sdk.getComponent('elements.EditableText'); + const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); const avatarUrl = ( this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null @@ -1198,8 +1213,9 @@ module.exports = React.createClass({ onCancelClick={this.props.onClose} /> - +

    { _t("Profile") }

    @@ -1312,7 +1328,7 @@ module.exports = React.createClass({ { this._renderDeactivateAccount() } -
    +
    ); }, diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 43753bfd38..ca50b9db6e 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -18,10 +18,12 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import Modal from "../../../Modal"; import MatrixClientPeg from "../../../MatrixClientPeg"; +import SdkConfig from "../../../SdkConfig"; import PasswordReset from "../../../PasswordReset"; @@ -29,13 +31,13 @@ module.exports = React.createClass({ displayName: 'ForgotPassword', propTypes: { - defaultHsUrl: React.PropTypes.string, - defaultIsUrl: React.PropTypes.string, - customHsUrl: React.PropTypes.string, - customIsUrl: React.PropTypes.string, - onLoginClick: React.PropTypes.func, - onRegisterClick: React.PropTypes.func, - onComplete: React.PropTypes.func.isRequired, + defaultHsUrl: PropTypes.string, + defaultIsUrl: PropTypes.string, + customHsUrl: PropTypes.string, + customIsUrl: PropTypes.string, + onLoginClick: PropTypes.func, + onRegisterClick: PropTypes.func, + onComplete: PropTypes.func.isRequired, }, getInitialState: function() { @@ -184,7 +186,7 @@ module.exports = React.createClass({ ); } else { let serverConfigSection; - if (!config.disable_custom_urls) { + if (!SdkConfig.get().disable_custom_urls) { serverConfigSection = ( { _t('Sign in') }; + header =

    { _t('Sign in') } { loader }

    ; } else { if (!this.state.errorText) { - header =

    { _t('Sign in to get started') }

    ; + header =

    { _t('Sign in to get started') } { loader }

    ; } } diff --git a/src/components/structures/login/PostRegistration.js b/src/components/structures/login/PostRegistration.js index 184356e852..f6165348bd 100644 --- a/src/components/structures/login/PostRegistration.js +++ b/src/components/structures/login/PostRegistration.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; @@ -25,7 +26,7 @@ module.exports = React.createClass({ displayName: 'PostRegistration', propTypes: { - onComplete: React.PropTypes.func.isRequired, + onComplete: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index e57b7fd0c2..62a3ee4f68 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -19,6 +19,7 @@ import Matrix from 'matrix-js-sdk'; import Promise from 'bluebird'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import ServerConfig from '../../views/login/ServerConfig'; @@ -35,31 +36,32 @@ module.exports = React.createClass({ displayName: 'Registration', propTypes: { - onLoggedIn: React.PropTypes.func.isRequired, - clientSecret: React.PropTypes.string, - sessionId: React.PropTypes.string, - makeRegistrationUrl: React.PropTypes.func.isRequired, - idSid: React.PropTypes.string, - customHsUrl: React.PropTypes.string, - customIsUrl: React.PropTypes.string, - defaultHsUrl: React.PropTypes.string, - defaultIsUrl: React.PropTypes.string, - brand: React.PropTypes.string, - email: React.PropTypes.string, - referrer: React.PropTypes.string, - teamServerConfig: React.PropTypes.shape({ + onLoggedIn: PropTypes.func.isRequired, + clientSecret: PropTypes.string, + sessionId: PropTypes.string, + makeRegistrationUrl: PropTypes.func.isRequired, + idSid: PropTypes.string, + customHsUrl: PropTypes.string, + customIsUrl: PropTypes.string, + defaultHsUrl: PropTypes.string, + defaultIsUrl: PropTypes.string, + brand: PropTypes.string, + email: PropTypes.string, + referrer: PropTypes.string, + teamServerConfig: PropTypes.shape({ // 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 - 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. - onLoginClick: React.PropTypes.func.isRequired, - onCancelClick: React.PropTypes.func, + onLoginClick: PropTypes.func.isRequired, + onCancelClick: PropTypes.func, + onServerConfigChange: PropTypes.func.isRequired, }, getInitialState: function() { @@ -130,6 +132,7 @@ module.exports = React.createClass({ if (config.isUrl !== undefined) { newState.isUrl = config.isUrl; } + this.props.onServerConfigChange(config); this.setState(newState, function() { this._replaceClient(); }); diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index f68e98ec3d..6fb86c9cd8 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,6 +16,8 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; +import { MatrixClient } from 'matrix-js-sdk'; import AvatarLogic from '../../../Avatar'; import sdk from '../../../index'; import AccessibleButton from '../elements/AccessibleButton'; @@ -23,16 +26,20 @@ module.exports = React.createClass({ displayName: 'BaseAvatar', propTypes: { - name: React.PropTypes.string.isRequired, // The name (first initial used as default) - idName: React.PropTypes.string, // ID for generating hash colours - title: React.PropTypes.string, // onHover title text - url: React.PropTypes.string, // highest priority of them all, shortcut to set in urls[0] - urls: React.PropTypes.array, // [highest_priority, ... , lowest_priority] - width: React.PropTypes.number, - height: React.PropTypes.number, + name: PropTypes.string.isRequired, // The name (first initial used as default) + idName: PropTypes.string, // ID for generating hash colours + title: PropTypes.string, // onHover title text + url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0] + urls: PropTypes.array, // [highest_priority, ... , lowest_priority] + width: PropTypes.number, + height: PropTypes.number, // XXX resizeMethod not actually used. - resizeMethod: React.PropTypes.string, - defaultToInitialLetter: React.PropTypes.bool, // true to add default url + resizeMethod: PropTypes.string, + defaultToInitialLetter: PropTypes.bool, // true to add default url + }, + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), }, getDefaultProps: function() { @@ -48,6 +55,16 @@ module.exports = React.createClass({ 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) { // work out if we need to call setState (if the image URLs array has changed) 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) { // work out the full set of urls to try to load. This is formed like so: // imageUrls: [ props.url, props.urls, default image ] diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index 89047cd69c..a4fe5e280f 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; const React = require('react'); +import PropTypes from 'prop-types'; const Avatar = require('../../../Avatar'); const sdk = require("../../../index"); const dispatcher = require("../../../dispatcher"); @@ -25,15 +26,15 @@ module.exports = React.createClass({ displayName: 'MemberAvatar', propTypes: { - member: React.PropTypes.object.isRequired, - width: React.PropTypes.number, - height: React.PropTypes.number, - resizeMethod: React.PropTypes.string, + member: PropTypes.object.isRequired, + width: PropTypes.number, + height: PropTypes.number, + resizeMethod: PropTypes.string, // 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' - viewUserOnClick: React.PropTypes.bool, - title: React.PropTypes.string, + viewUserOnClick: PropTypes.bool, + title: PropTypes.string, }, getDefaultProps: function() { diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js index 11554b2379..876f40c52f 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from "react"; +import PropTypes from 'prop-types'; import {ContentRepo} from "matrix-js-sdk"; import MatrixClientPeg from "../../../MatrixClientPeg"; import sdk from "../../../index"; @@ -25,11 +26,11 @@ module.exports = React.createClass({ // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) propTypes: { - room: React.PropTypes.object, - oobData: React.PropTypes.object, - width: React.PropTypes.number, - height: React.PropTypes.number, - resizeMethod: React.PropTypes.string, + room: PropTypes.object, + oobData: PropTypes.object, + width: PropTypes.number, + height: PropTypes.number, + resizeMethod: PropTypes.string, }, 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) { this.setState({ 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) { return [ ContentRepo.getHttpUriForMxc( @@ -86,10 +109,15 @@ module.exports = React.createClass({ const mlist = props.room.currentState.members; const userIds = []; + const leftUserIds = []; // for .. in optimisation to return early if there are >2 keys for (const uid in mlist) { if (mlist.hasOwnProperty(uid)) { - userIds.push(uid); + if (["join", "invite"].includes(mlist[uid].membership)) { + userIds.push(uid); + } else { + leftUserIds.push(uid); + } } if (userIds.length > 2) { return null; @@ -111,6 +139,14 @@ module.exports = React.createClass({ false, ); } else if (userIds.length == 1) { + // The other 1-1 user left, leaving just the current user, so show the left user's avatar + if (leftUserIds.length === 1) { + return mlist[leftUserIds[0]].getAvatarUrl( + MatrixClientPeg.get().getHomeserverUrl(), + props.width, props.height, props.resizeMethod, + false, + ); + } return mlist[userIds[0]].getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), Math.floor(props.width * window.devicePixelRatio), diff --git a/src/components/views/create_room/CreateRoomButton.js b/src/components/views/create_room/CreateRoomButton.js index 8a5f00d942..25f71f542d 100644 --- a/src/components/views/create_room/CreateRoomButton.js +++ b/src/components/views/create_room/CreateRoomButton.js @@ -17,11 +17,12 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'CreateRoomButton', propTypes: { - onCreateRoom: React.PropTypes.func, + onCreateRoom: PropTypes.func, }, getDefaultProps: function() { diff --git a/src/components/views/create_room/Presets.js b/src/components/views/create_room/Presets.js index 2073896d87..c9607c0082 100644 --- a/src/components/views/create_room/Presets.js +++ b/src/components/views/create_room/Presets.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; const React = require('react'); +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; const Presets = { @@ -28,8 +29,8 @@ const Presets = { module.exports = React.createClass({ displayName: 'CreateRoomPresets', propTypes: { - onChange: React.PropTypes.func, - preset: React.PropTypes.string, + onChange: PropTypes.func, + preset: PropTypes.string, }, Presets: Presets, diff --git a/src/components/views/create_room/RoomAlias.js b/src/components/views/create_room/RoomAlias.js index d4228a8bca..6262db7833 100644 --- a/src/components/views/create_room/RoomAlias.js +++ b/src/components/views/create_room/RoomAlias.js @@ -15,6 +15,7 @@ limitations under the License. */ const React = require('react'); +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; module.exports = React.createClass({ @@ -22,9 +23,9 @@ module.exports = React.createClass({ propTypes: { // Specifying a homeserver will make magical things happen when you, // e.g. start typing in the room alias box. - homeserver: React.PropTypes.string, - alias: React.PropTypes.string, - onChange: React.PropTypes.func, + homeserver: PropTypes.string, + alias: PropTypes.string, + onChange: PropTypes.func, }, getDefaultProps: function() { diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 837d2f5349..685c4fcde3 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -20,7 +20,6 @@ import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import AccessibleButton from '../elements/AccessibleButton'; import Promise from 'bluebird'; import { addressTypes, getAddressType } from '../../../UserAddress.js'; import GroupStoreCache from '../../../stores/GroupStoreCache'; @@ -507,7 +506,8 @@ module.exports = React.createClass({ }, 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"); this.scrollElement = null; @@ -580,14 +580,8 @@ module.exports = React.createClass({ } return ( -
    -
    - { this.props.title } -
    - - - +
    @@ -597,12 +591,10 @@ module.exports = React.createClass({ { addressSelector } { this.props.extraNode }
    -
    - -
    -
    + + ); }, }); diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index b88a6c026e..21a2477c37 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -15,10 +15,15 @@ limitations under the License. */ 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 AccessibleButton from '../elements/AccessibleButton'; import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; /** * Basic container for modal dialogs. @@ -31,33 +36,48 @@ export default React.createClass({ propTypes: { // onFinished callback to call when Escape is pressed - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, - // callback to call when Enter is pressed - onEnterPressed: React.PropTypes.func, + // called when a key is pressed + onKeyDown: PropTypes.func, // CSS class to apply to dialog div - className: React.PropTypes.string, + className: PropTypes.string, // Title for the dialog. // (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: 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) { + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } if (e.keyCode === KeyCode.ESCAPE) { e.stopPropagation(); e.preventDefault(); 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"); return ( -
    + -
    +
    { this.props.title }
    { this.props.children } -
    +
    ); }, }); diff --git a/src/components/views/dialogs/ChatCreateOrReuseDialog.js b/src/components/views/dialogs/ChatCreateOrReuseDialog.js index e0578f3b53..e2387064cf 100644 --- a/src/components/views/dialogs/ChatCreateOrReuseDialog.js +++ b/src/components/views/dialogs/ChatCreateOrReuseDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; @@ -58,6 +59,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { ); tiles.push(
    { _t("Start new chat") }
    ; - content =
    + content =
    { _t('You already have existing direct chats with this user:') }
    { this.state.tiles } @@ -137,6 +139,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { } else { // Show the avatar, name and a button to confirm that a new chat is requested const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Spinner = sdk.getComponent('elements.Spinner'); title = _t('Start chatting'); @@ -144,7 +147,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { if (this.state.busyProfile) { profile = ; } else if (this.state.profileError) { - profile =
    + profile =
    Unable to load profile information for { this.props.userId }
    ; } else { @@ -160,17 +163,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
    ; } content =
    -
    +

    { _t('Click on the button below to start chatting!') }

    { profile }
    -
    - -
    +
    ; } @@ -179,6 +179,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { { content } @@ -187,9 +188,9 @@ export default class ChatCreateOrReuseDialog extends React.Component { } ChatCreateOrReuseDialog.propTyps = { - userId: React.PropTypes.string.isRequired, + userId: PropTypes.string.isRequired, // Called when clicking outside of the dialog - onFinished: React.PropTypes.func.isRequired, - onNewDMClick: React.PropTypes.func.isRequired, - onExistingRoomSelected: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, + onNewDMClick: PropTypes.func.isRequired, + onExistingRoomSelected: PropTypes.func.isRequired, }; diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 78d084b709..b65d98d78d 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -15,10 +15,10 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import classnames from 'classnames'; import { GroupMemberType } from '../../../groups'; /* @@ -33,20 +33,20 @@ export default React.createClass({ displayName: 'ConfirmUserActionDialog', propTypes: { // 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' groupMember: GroupMemberType, // needed if a group member is specified - matrixClient: React.PropTypes.instanceOf(MatrixClient), - action: React.PropTypes.string.isRequired, // eg. 'Ban' - title: React.PropTypes.string.isRequired, // eg. 'Ban this user?' + matrixClient: PropTypes.instanceOf(MatrixClient), + action: PropTypes.string.isRequired, // eg. 'Ban' + title: PropTypes.string.isRequired, // eg. 'Ban this user?' // Whether to display a text field for a reason // If true, the second argument to onFinished will // be the string entered. - askReason: React.PropTypes.bool, - danger: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + askReason: PropTypes.bool, + danger: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, defaultProps: { @@ -76,13 +76,11 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - const confirmButtonClass = classnames({ - 'mx_Dialog_primary': true, - 'danger': this.props.danger, - }); + const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; if (this.props.askReason) { @@ -116,10 +114,10 @@ export default React.createClass({ return ( -
    +
    { avatar }
    @@ -127,17 +125,11 @@ export default React.createClass({
    { userId }
    { reasonBox } -
    - - - -
    + ); }, diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 168fe75947..04f99a0e15 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -55,11 +55,15 @@ export default React.createClass({ _checkGroupId: function(e) { 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 '=_-./'"); } this.setState({ groupIdError: error, + // Reset createError to get rid of now stale error message + createError: null, }); return error; }, @@ -108,7 +112,7 @@ export default React.createClass({ // 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 // any yet. - createErrorNode =
    + createErrorNode =
    { _t('Something went wrong whilst creating your community') }
    { this.state.createError.message }
    ; @@ -116,7 +120,6 @@ export default React.createClass({ return (
    @@ -159,10 +162,10 @@ export default React.createClass({ { createErrorNode }
    + -
    diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index f7be47b3eb..51693a19c9 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import { _t } from '../../../languageHandler'; @@ -22,7 +23,7 @@ import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'CreateRoomDialog', propTypes: { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, }, componentDidMount: function() { @@ -41,40 +42,37 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( -
    -
    - -
    -
    - -
    -
    - -
    - { _t('Advanced options') } -
    - - +
    +
    +
    +
    -
    -
    -
    - - -
    +
    + +
    +
    + +
    + { _t('Advanced options') } +
    + + +
    +
    +
    + +
    ); }, diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index c45e072d72..87228b4733 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import Analytics from '../../../Analytics'; @@ -77,6 +78,7 @@ export default class DeactivateAccountDialog extends React.Component { } render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Loader = sdk.getComponent("elements.Spinner"); let passwordBoxClass = ''; @@ -99,10 +101,11 @@ export default class DeactivateAccountDialog extends React.Component { } return ( -
    -
    - { _t("Deactivate Account") } -
    +

    { _t("This will make your account permanently unusable. You will not be able to re-register the same user ID.") }

    @@ -130,11 +133,11 @@ export default class DeactivateAccountDialog extends React.Component { { cancelButton }
    -
    + ); } } DeactivateAccountDialog.propTypes = { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, }; diff --git a/src/components/views/dialogs/DeviceVerifyDialog.js b/src/components/views/dialogs/DeviceVerifyDialog.js index ba31d2a8c2..6bec933389 100644 --- a/src/components/views/dialogs/DeviceVerifyDialog.js +++ b/src/components/views/dialogs/DeviceVerifyDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import * as FormattingUtils from '../../../utils/FormattingUtils'; @@ -71,7 +72,7 @@ export default function DeviceVerifyDialog(props) { } DeviceVerifyDialog.propTypes = { - userId: React.PropTypes.string.isRequired, - device: React.PropTypes.object.isRequired, - onFinished: React.PropTypes.func.isRequired, + userId: PropTypes.string.isRequired, + device: PropTypes.object.isRequired, + onFinished: PropTypes.func.isRequired, }; diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index 97ed47e10f..a055f07629 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -26,20 +26,21 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'ErrorDialog', propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, + title: PropTypes.string, + description: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.string, ]), - button: React.PropTypes.string, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + button: PropTypes.string, + focus: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { @@ -51,22 +52,18 @@ export default React.createClass({ }; }, - componentDidMount: function() { - if (this.props.focus) { - this.refs.button.focus(); - } - }, - render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
    + title={this.props.title || _t('Error')} + contentId='mx_Dialog_content' + > +
    { this.props.description || _t('An error has occurred.') }
    -
    diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 59de7c7f59..b682156072 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -27,22 +28,22 @@ export default React.createClass({ propTypes: { // 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 // mount. - authData: React.PropTypes.shape({ - flows: React.PropTypes.array, - params: React.PropTypes.object, - session: React.PropTypes.string, + authData: PropTypes.shape({ + flows: PropTypes.array, + params: PropTypes.object, + session: PropTypes.string, }), // 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() { @@ -72,11 +73,12 @@ export default React.createClass({ let content; if (this.state.authError) { content = ( -
    -
    { this.state.authError.message || this.state.authError.toString() }
    +
    +
    { this.state.authError.message || this.state.authError.toString() }

    { _t("Dismiss") } @@ -84,7 +86,7 @@ export default React.createClass({ ); } else { content = ( -
    +
    { content } diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index 9c8be27c89..b9b64a69d2 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -16,6 +16,7 @@ limitations under the License. import Modal from '../../../Modal'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; @@ -30,10 +31,10 @@ import { _t, _td } from '../../../languageHandler'; */ export default React.createClass({ propTypes: { - matrixClient: React.PropTypes.object.isRequired, - userId: React.PropTypes.string.isRequired, - deviceId: React.PropTypes.string.isRequired, - onFinished: React.PropTypes.func.isRequired, + matrixClient: PropTypes.object.isRequired, + userId: PropTypes.string.isRequired, + deviceId: PropTypes.string.isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { @@ -125,11 +126,11 @@ export default React.createClass({ text = _t(text, {displayName: displayName}); return ( -
    +

    { text }

    - - ) : null; - const buttonClasses = classnames({ - mx_Dialog_primary: true, - danger: this.props.danger, - }); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + let primaryButtonClass = ""; + if (this.props.danger) { + primaryButtonClass = "danger"; + } return ( -
    +
    { this.props.description }
    -
    - + { this.props.extraButtons } - { cancelButton } -
    + ); }, diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index 75ae0eda17..451785197e 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; @@ -25,8 +26,14 @@ export default React.createClass({ displayName: 'SessionRestoreErrorDialog', propTypes: { - error: React.PropTypes.string.isRequired, - onFinished: React.PropTypes.func.isRequired, + error: PropTypes.string.isRequired, + onFinished: PropTypes.func.isRequired, + }, + + componentDidMount: function() { + if (this.refs.bugreportLink) { + this.refs.bugreportLink.focus(); + } }, _sendBugReport: function() { @@ -40,6 +47,7 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); let bugreport; if (SdkConfig.get().bug_report_endpoint_url) { @@ -48,16 +56,20 @@ export default React.createClass({ { _t( "Otherwise, click here to send a bug report.", {}, - { 'a': (sub) => { sub } }, + { 'a': (sub) => { sub } }, ) }

    ); } + const shouldFocusContinueButton =!(bugreport==true); return ( -
    + title={_t('Unable to restore session')} + contentId='mx_Dialog_content' + > +

    { _t("We encountered an error trying to restore your previous session. If " + "you continue, you will need to log in again, and encrypted chat " + "history will be unreadable.") }

    @@ -68,11 +80,9 @@ export default React.createClass({ { bugreport }
    -
    - -
    + ); }, diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index 2dd996953d..d80574804f 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import Email from '../../../email'; import AddThreepid from '../../../AddThreepid'; @@ -30,7 +31,7 @@ import Modal from '../../../Modal'; export default React.createClass({ displayName: 'SetEmailDialog', propTypes: { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { @@ -40,9 +41,6 @@ export default React.createClass({ }; }, - componentDidMount: function() { - }, - onEmailAddressChanged: function(value) { this.setState({ emailAddress: value, @@ -130,6 +128,7 @@ export default React.createClass({ const emailInput = this.state.emailBusy ? :
    -

    +

    { _t('This will allow you to reset your password and receive notifications.') }

    { emailInput } diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index 3ffafb0659..c6427001ad 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -17,6 +17,7 @@ limitations under the License. import Promise from 'bluebird'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import classnames from 'classnames'; @@ -35,11 +36,11 @@ const USERNAME_CHECK_DEBOUNCE_MS = 250; export default React.createClass({ displayName: 'SetMxIdDialog', propTypes: { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, // 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 - onLoginClick: React.PropTypes.func.isRequired, + onLoginClick: PropTypes.func.isRequired, }, getInitialState: function() { @@ -234,14 +235,14 @@ export default React.createClass({ "error": Boolean(this.state.usernameError), "success": usernameAvailable, }); - usernameIndicator =
    + usernameIndicator =
    { usernameAvailable ? _t('Username available') : this.state.usernameError }
    ; } let authErrorIndicator = null; if (this.state.authError) { - authErrorIndicator =
    + authErrorIndicator =
    { this.state.authError }
    ; } @@ -253,8 +254,9 @@ export default React.createClass({ -
    +
    -
    -
    - +
    +
    +
    + +
    +
    + +
    -
    - -
    -
    -
    - - -
    + + ); }, diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js index 9c19ee6eca..07f8b6187f 100644 --- a/src/components/views/dialogs/UnknownDeviceDialog.js +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -23,14 +23,7 @@ import GeminiScrollbar from 'react-gemini-scrollbar'; import Resend from '../../../Resend'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; - -function markAllDevicesKnown(devices) { - Object.keys(devices).forEach((userId) => { - Object.keys(devices[userId]).map((deviceId) => { - MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true); - }); - }); -} +import { markAllDevicesKnown } from '../../../cryptodevices'; function DeviceListEntry(props) { const {userId, device} = props; @@ -141,7 +134,7 @@ export default React.createClass({ }, _onSendAnywayClicked: function() { - markAllDevicesKnown(this.props.devices); + markAllDevicesKnown(MatrixClientPeg.get(), this.props.devices); this.props.onFinished(); this.props.onSend(); @@ -153,6 +146,7 @@ export default React.createClass({ }, render: function() { + const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); if (this.props.devices === null) { const Spinner = sdk.getComponent("elements.Spinner"); return ; @@ -187,24 +181,18 @@ export default React.createClass({ } }); }); - let sendButton; - if (haveUnknownDevices) { - sendButton = ; - } else { - sendButton = ; - } + const sendButtonOnClick = haveUnknownDevices ? this._onSendAnywayClicked : this._onSendClicked; + const sendButtonLabel = haveUnknownDevices ? this.props.sendAnywayLabel : this.props.sendAnywayLabel; const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( - +

    { _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) }

    @@ -212,15 +200,10 @@ export default React.createClass({ { _t("Unknown devices") }: -
    -
    - {sendButton} - -
    + +
    ); // XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point? diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js index 794e0a4dd7..e30ceb85fa 100644 --- a/src/components/views/elements/AccessibleButton.js +++ b/src/components/views/elements/AccessibleButton.js @@ -15,6 +15,9 @@ */ 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 @@ -27,8 +30,34 @@ import React from 'react'; export default function AccessibleButton(props) { const {element, onClick, children, ...restProps} = props; 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) { - 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.role = "button"; @@ -44,9 +73,9 @@ export default function AccessibleButton(props) { * implemented exactly like a normal onClick handler. */ AccessibleButton.propTypes = { - children: React.PropTypes.node, - element: React.PropTypes.string, - onClick: React.PropTypes.func.isRequired, + children: PropTypes.node, + element: PropTypes.string, + onClick: PropTypes.func.isRequired, }; AccessibleButton.defaultProps = { diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 9330206a39..b4279c7f70 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -18,6 +18,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import classNames from 'classnames'; import { UserAddressType } from '../../../UserAddress'; @@ -26,17 +27,17 @@ export default React.createClass({ displayName: 'AddressSelector', propTypes: { - onSelected: React.PropTypes.func.isRequired, + onSelected: PropTypes.func.isRequired, // 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 - showAddress: React.PropTypes.bool, - truncateAt: React.PropTypes.number.isRequired, - selected: React.PropTypes.number, + showAddress: PropTypes.bool, + truncateAt: PropTypes.number.isRequired, + selected: PropTypes.number, // Element to put as a header on top of the list - header: React.PropTypes.node, + header: PropTypes.node, }, getInitialState: function() { diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index c8ea4062b1..16e340756a 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; import sdk from "../../../index"; import MatrixClientPeg from "../../../MatrixClientPeg"; @@ -28,9 +29,9 @@ export default React.createClass({ propTypes: { address: UserAddressType.isRequired, - canDismiss: React.PropTypes.bool, - onDismissed: React.PropTypes.func, - justified: React.PropTypes.bool, + canDismiss: PropTypes.bool, + onDismissed: PropTypes.func, + justified: PropTypes.bool, }, getDefaultProps: function() { diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 0d67b4c814..41be5738af 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -1,4 +1,4 @@ -/* +/** Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); @@ -19,6 +19,7 @@ limitations under the License. import url from 'url'; import qs from 'querystring'; import React from 'react'; +import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; import PlatformPeg from '../../../PlatformPeg'; import ScalarAuthClient from '../../../ScalarAuthClient'; @@ -35,32 +36,25 @@ import WidgetUtils from '../../../WidgetUtils'; import dis from '../../../dispatcher'; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; +const ENABLE_REACT_PERF = false; -export default React.createClass({ - displayName: 'AppTile', +export default class AppTile extends React.Component { + constructor(props) { + super(props); + this.state = this._getNewState(props); - propTypes: { - id: React.PropTypes.string.isRequired, - url: React.PropTypes.string.isRequired, - name: React.PropTypes.string.isRequired, - room: React.PropTypes.object.isRequired, - type: React.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: React.PropTypes.bool, - // UserId of the current user - userId: React.PropTypes.string.isRequired, - // UserId of the entity that added / modified the widget - creatorUserId: React.PropTypes.string, - waitForIframeLoad: React.PropTypes.bool, - }, - - getDefaultProps() { - return { - url: "", - waitForIframeLoad: true, - }; - }, + this._onWidgetAction = this._onWidgetAction.bind(this); + this._onMessage = this._onMessage.bind(this); + this._onLoaded = this._onLoaded.bind(this); + this._onEditClick = this._onEditClick.bind(this); + this._onDeleteClick = this._onDeleteClick.bind(this); + this._onSnapshotClick = this._onSnapshotClick.bind(this); + this.onClickMenuBar = this.onClickMenuBar.bind(this); + this._onMinimiseClick = this._onMinimiseClick.bind(this); + this._onInitialLoad = this._onInitialLoad.bind(this); + this._grantWidgetPermission = this._grantWidgetPermission.bind(this); + this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this); + } /** * Set initial component state when the App wUrl (widget URL) is being updated. @@ -72,8 +66,8 @@ export default React.createClass({ const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_'); const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); return { - initialising: true, // True while we are mangling the widget URL - loading: this.props.waitForIframeLoad, // True while the iframe content is loading + initialising: true, // True while we are mangling the widget URL + loading: this.props.waitForIframeLoad, // True while the iframe content is loading widgetUrl: this._addWurlParams(newProps.url), widgetPermissionId: widgetPermissionId, // Assume that widget has permission to load if we are the user who @@ -82,8 +76,20 @@ export default React.createClass({ error: null, deleting: false, 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 @@ -111,11 +117,7 @@ export default React.createClass({ u.query = params; return u.format(); - }, - - getInitialState() { - return this._getNewState(this.props); - }, + } /** * Returns true if specified url is a scalar URL, typically https://scalar.vector.im/api @@ -139,7 +141,7 @@ export default React.createClass({ } } return false; - }, + } isMixedContent() { const parentContentProtocol = window.location.protocol; @@ -151,14 +153,36 @@ export default React.createClass({ return true; } return false; - }, + } componentWillMount() { - WidgetMessaging.startListening(); - WidgetMessaging.addEndpoint(this.props.id, this.props.url); - window.addEventListener('message', this._onMessage, false); 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 @@ -210,13 +234,7 @@ export default React.createClass({ initialising: false, }); }); - }, - - componentWillUnmount() { - WidgetMessaging.stopListening(); - WidgetMessaging.removeEndpoint(this.props.id, this.props.url); - window.removeEventListener('message', this._onMessage); - }, + } componentWillReceiveProps(nextProps) { if (nextProps.url !== this.props.url) { @@ -231,8 +249,10 @@ export default React.createClass({ widgetPageTitle: nextProps.widgetPageTitle, }); } - }, + } + // Legacy Jitsi widget messaging + // TODO -- This should be replaced with the new widget postMessaging API _onMessage(event) { if (this.props.type !== 'jitsi') { return; @@ -250,63 +270,140 @@ export default React.createClass({ .document.querySelector('iframe[id^="jitsiConferenceFrame"]'); PlatformPeg.get().setupScreenSharingForIframe(iframe); } - }, + } _canUserModify() { return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); - }, + } _onEditClick(e) { console.log("Edit widget ID ", this.props.id); - const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - const src = this._scalarClient.getScalarInterfaceUrlForRoom( - this.props.room.roomId, 'type_' + this.props.type, this.props.id); - Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { - src: src, - }, "mx_IntegrationsManager"); - }, + if (this.props.onEditClick) { + this.props.onEditClick(); + } else { + const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); + const src = this._scalarClient.getScalarInterfaceUrlForRoom( + this.props.room, 'type_' + this.props.type, this.props.id); + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { + src: src, + }, "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, * otherwise revoke access for the widget to load in the user's browser */ _onDeleteClick() { - if (this._canUserModify()) { - // Show delete confirmation dialog - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { - title: _t("Delete Widget"), - description: _t( - "Deleting a widget removes it for all users in this room." + - " Are you sure you want to delete this widget?"), - button: _t("Delete widget"), - onFinished: (confirmed) => { - if (!confirmed) { - return; - } - this.setState({deleting: true}); - MatrixClientPeg.get().sendStateEvent( - this.props.room.roomId, - 'im.vector.modular.widgets', - {}, // empty content - this.props.id, - ).catch((e) => { - console.error('Failed to delete widget', e); - this.setState({deleting: false}); - }); - }, - }); + if (this.props.onDeleteClick) { + this.props.onDeleteClick(); } else { - console.log("Revoke widget permissions - %s", this.props.id); - this._revokeWidgetPermission(); + if (this._canUserModify()) { + // Show delete confirmation dialog + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) { + return; + } + this.setState({deleting: true}); + MatrixClientPeg.get().sendStateEvent( + this.props.room.roomId, + 'im.vector.modular.widgets', + {}, // empty content + this.props.id, + ).catch((e) => { + console.error('Failed to delete widget', e); + }).finally(() => { + this.setState({deleting: false}); + }); + }, + }); + } else { + console.log("Revoke widget permissions - %s", this.props.id); + this._revokeWidgetPermission(); + } } - }, + } /** * Called when widget iframe has finished loading */ _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}); - }, + } + + _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 @@ -320,7 +417,7 @@ export default React.createClass({ }, (err) =>{ console.error("Failed to get page title", err); }); - }, + } // 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 @@ -329,20 +426,20 @@ export default React.createClass({ return _td('Delete widget'); } return _td('Revoke widget access'); - }, + } /* TODO -- Store permission in account data so that it is persisted across multiple devices */ _grantWidgetPermission() { console.warn('Granting permission to load widget - ', this.state.widgetUrl); localStorage.setItem(this.state.widgetPermissionId, true); this.setState({hasPermissionToLoad: true}); - }, + } _revokeWidgetPermission() { console.warn('Revoking permission to load widget - ', this.state.widgetUrl); localStorage.removeItem(this.state.widgetPermissionId); this.setState({hasPermissionToLoad: false}); - }, + } formatAppTileName() { let appTileName = "No name"; @@ -350,7 +447,7 @@ export default React.createClass({ appTileName = this.props.name.trim(); } return appTileName; - }, + } onClickMenuBar(ev) { ev.preventDefault(); @@ -365,16 +462,42 @@ export default React.createClass({ action: 'appsDrawer', show: !this.props.show, }); - }, + } _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 = ''; if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { safeWidgetUrl = url.format(parsedWidgetUrl); } return safeWidgetUrl; - }, + } + + _getTileTitle() { + const name = this.formatAppTileName(); + const titleSpacer =  - ; + let title = ''; + if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { + title = this.state.widgetPageTitle; + } + + return ( + + { name } + { title ? titleSpacer : '' }{ title } + + ); + } + + _onMinimiseClick(e) { + if (this.props.onMinimiseClick) { + this.props.onMinimiseClick(); + } + } render() { let appTileBody; @@ -392,9 +515,13 @@ export default React.createClass({ const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ "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) { const loadingElement = ( -
    +
    ); @@ -409,9 +536,15 @@ export default React.createClass({ ); } else { appTileBody = ( -
    +
    { 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. + */ }