diff --git a/.eslintrc.js b/.eslintrc.js index 429aa24993..fd4d1da631 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -33,6 +33,8 @@ module.exports = { // This just uses the react plugin to help eslint known when // variables have been used in JSX "react/jsx-uses-vars": "error", + // Don't mark React as unused if we're using JSX + "react/jsx-uses-react": "error", // bind or arrow function in props causes performance issues "react/jsx-no-bind": ["error", { diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c2af18e43..86a1002a48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,267 @@ +Changes in [0.11.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0) (2017-11-15) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.3...v0.11.0) + + +Changes in [0.11.0-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.3) (2017-11-14) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.2...v0.11.0-rc.3) + + +Changes in [0.11.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.2) (2017-11-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.0-rc.1...v0.11.0-rc.2) + + * Make groups a fully-fleged baked-in feature + [\#1603](https://github.com/matrix-org/matrix-react-sdk/pull/1603) + +Changes in [0.11.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.0-rc.1) (2017-11-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7...v0.11.0-rc.1) + + * Improve widget rendering on prop updates + [\#1548](https://github.com/matrix-org/matrix-react-sdk/pull/1548) + * Display group member profile (avatar/displayname) in ConfirmUserActionDialog + [\#1595](https://github.com/matrix-org/matrix-react-sdk/pull/1595) + * Don't crash if there isn't a room notif rule + [\#1602](https://github.com/matrix-org/matrix-react-sdk/pull/1602) + * Show group name in flair tooltip if one is set + [\#1596](https://github.com/matrix-org/matrix-react-sdk/pull/1596) + * Convert group avatar URL to HTTP before handing to BaseAvatar + [\#1597](https://github.com/matrix-org/matrix-react-sdk/pull/1597) + * Add group features as starting points for ILAG + [\#1601](https://github.com/matrix-org/matrix-react-sdk/pull/1601) + * Modify the group room visibility API to reflect the js-sdk changes + [\#1598](https://github.com/matrix-org/matrix-react-sdk/pull/1598) + * Update from Weblate. + [\#1599](https://github.com/matrix-org/matrix-react-sdk/pull/1599) + * Revert "UnknownDeviceDialog: get devices from SDK" + [\#1594](https://github.com/matrix-org/matrix-react-sdk/pull/1594) + * Order users in the group member list with admins first + [\#1591](https://github.com/matrix-org/matrix-react-sdk/pull/1591) + * Fetch group members after accepting an invite + [\#1592](https://github.com/matrix-org/matrix-react-sdk/pull/1592) + * Improve address picker for rooms + [\#1589](https://github.com/matrix-org/matrix-react-sdk/pull/1589) + * Fix FlairStore getPublicisedGroupsCached to give the correct, existing + promise + [\#1590](https://github.com/matrix-org/matrix-react-sdk/pull/1590) + * Use the getProfileInfo API for group inviter profile + [\#1585](https://github.com/matrix-org/matrix-react-sdk/pull/1585) + * Add checkbox to GroupAddressPicker for determining visibility of group rooms + [\#1587](https://github.com/matrix-org/matrix-react-sdk/pull/1587) + * Alter group member api + [\#1581](https://github.com/matrix-org/matrix-react-sdk/pull/1581) + * Improve group creation UX + [\#1580](https://github.com/matrix-org/matrix-react-sdk/pull/1580) + * Disable RoomDetailList in GroupView when editing + [\#1583](https://github.com/matrix-org/matrix-react-sdk/pull/1583) + * Default to no read pins if there is no applicable account data + [\#1586](https://github.com/matrix-org/matrix-react-sdk/pull/1586) + * UnknownDeviceDialog: get devices from SDK + [\#1584](https://github.com/matrix-org/matrix-react-sdk/pull/1584) + * Add a small indicator for when a new event is pinned + [\#1486](https://github.com/matrix-org/matrix-react-sdk/pull/1486) + * Implement tooltip for group rooms + [\#1582](https://github.com/matrix-org/matrix-react-sdk/pull/1582) + * Room notifs in autocomplete & composer + [\#1577](https://github.com/matrix-org/matrix-react-sdk/pull/1577) + * Ignore img tags in HTML if src is not specified + [\#1579](https://github.com/matrix-org/matrix-react-sdk/pull/1579) + * Indicate admins in the group member list with a sheriff badge + [\#1578](https://github.com/matrix-org/matrix-react-sdk/pull/1578) + * Remember whether widget drawer was hidden per-room + [\#1533](https://github.com/matrix-org/matrix-react-sdk/pull/1533) + * Throw an error when trying to create a group store with falsey groupId + [\#1576](https://github.com/matrix-org/matrix-react-sdk/pull/1576) + * Fixes React warning + [\#1571](https://github.com/matrix-org/matrix-react-sdk/pull/1571) + * Fix Flair not appearing due to missing this._usersInFlight + [\#1575](https://github.com/matrix-org/matrix-react-sdk/pull/1575) + * Use, if possible, a room's canonical or first alias when viewing the … + [\#1574](https://github.com/matrix-org/matrix-react-sdk/pull/1574) + * Add CSS classes to group ID input in CreateGroupDialog + [\#1573](https://github.com/matrix-org/matrix-react-sdk/pull/1573) + * Give autocomplete providers the room they're in + [\#1568](https://github.com/matrix-org/matrix-react-sdk/pull/1568) + * Fix multiple pills on one line + [\#1572](https://github.com/matrix-org/matrix-react-sdk/pull/1572) + * Fix group invites such that they look similar to room invites + [\#1570](https://github.com/matrix-org/matrix-react-sdk/pull/1570) + * Add a GeminiScrollbar to Your Communities + [\#1569](https://github.com/matrix-org/matrix-react-sdk/pull/1569) + * Fix multiple requests for publicised groups of given user + [\#1567](https://github.com/matrix-org/matrix-react-sdk/pull/1567) + * Add toggle to alter visibility of a room-group association + [\#1566](https://github.com/matrix-org/matrix-react-sdk/pull/1566) + * Pillify room notifs in the timeline + [\#1564](https://github.com/matrix-org/matrix-react-sdk/pull/1564) + * Implement simple GroupRoomInfo + [\#1563](https://github.com/matrix-org/matrix-react-sdk/pull/1563) + * turn NPE on flair resolution errors into a logged error + [\#1565](https://github.com/matrix-org/matrix-react-sdk/pull/1565) + * Less translation in parts + [\#1484](https://github.com/matrix-org/matrix-react-sdk/pull/1484) + * Redact group IDs from analytics + [\#1562](https://github.com/matrix-org/matrix-react-sdk/pull/1562) + * Display whether the group summary/room list is loading + [\#1560](https://github.com/matrix-org/matrix-react-sdk/pull/1560) + * Change client-side validation of group IDs to match synapse + [\#1558](https://github.com/matrix-org/matrix-react-sdk/pull/1558) + * Prevent non-members from opening group settings + [\#1559](https://github.com/matrix-org/matrix-react-sdk/pull/1559) + * Alter UI for disinviting a group member + [\#1556](https://github.com/matrix-org/matrix-react-sdk/pull/1556) + * Only show admin tools to privileged users + [\#1555](https://github.com/matrix-org/matrix-react-sdk/pull/1555) + * Try lowercase username on login + [\#1550](https://github.com/matrix-org/matrix-react-sdk/pull/1550) + * Don't refresh page on password change prompt + [\#1554](https://github.com/matrix-org/matrix-react-sdk/pull/1554) + * Fix initial in GroupAvatar in GroupView + [\#1553](https://github.com/matrix-org/matrix-react-sdk/pull/1553) + * Use "crop" method to scale group avatars in MyGroups + [\#1549](https://github.com/matrix-org/matrix-react-sdk/pull/1549) + * Lowercase all usernames + [\#1547](https://github.com/matrix-org/matrix-react-sdk/pull/1547) + * Add sensible missing entry generator for MELS tests + [\#1546](https://github.com/matrix-org/matrix-react-sdk/pull/1546) + * Fix prompt to re-use chat room + [\#1545](https://github.com/matrix-org/matrix-react-sdk/pull/1545) + * Add unregiseterListener to GroupStore + [\#1544](https://github.com/matrix-org/matrix-react-sdk/pull/1544) + * Fix groups invited users err for non members + [\#1543](https://github.com/matrix-org/matrix-react-sdk/pull/1543) + * Add Mention button to MemberInfo + [\#1532](https://github.com/matrix-org/matrix-react-sdk/pull/1532) + * Only show group settings cog to members + [\#1541](https://github.com/matrix-org/matrix-react-sdk/pull/1541) + * Use correct icon for group room deletion and make themeable + [\#1540](https://github.com/matrix-org/matrix-react-sdk/pull/1540) + * Add invite button to MemberInfo if user has left or wasn't in room + [\#1534](https://github.com/matrix-org/matrix-react-sdk/pull/1534) + * Add option to mirror local video feed + [\#1539](https://github.com/matrix-org/matrix-react-sdk/pull/1539) + * Use the correct userId when displaying who redacted a message + [\#1538](https://github.com/matrix-org/matrix-react-sdk/pull/1538) + * Only show editing UI for aliases/related_groups for users /w power + [\#1529](https://github.com/matrix-org/matrix-react-sdk/pull/1529) + * Swap from `ui_opacity` to `panel_disabled` + [\#1535](https://github.com/matrix-org/matrix-react-sdk/pull/1535) + * Fix room address picker tiles default name + [\#1536](https://github.com/matrix-org/matrix-react-sdk/pull/1536) + * T3chguy/hide level change on 50 + [\#1531](https://github.com/matrix-org/matrix-react-sdk/pull/1531) + * fix missing date sep caused by hidden event at start of day + [\#1537](https://github.com/matrix-org/matrix-react-sdk/pull/1537) + * Add a delete confirmation dialog for widgets + [\#1520](https://github.com/matrix-org/matrix-react-sdk/pull/1520) + * When dispatching view_[my_]group[s], reset RoomViewStore + [\#1530](https://github.com/matrix-org/matrix-react-sdk/pull/1530) + * Prevent editing of UI requiring user privilege if user unprivileged + [\#1528](https://github.com/matrix-org/matrix-react-sdk/pull/1528) + * Use the correct property of the API room objects + [\#1526](https://github.com/matrix-org/matrix-react-sdk/pull/1526) + * Don't include the |other in the translation value + [\#1527](https://github.com/matrix-org/matrix-react-sdk/pull/1527) + * Re-run gen-i18n after fixing https://github.com/matrix-org/matrix-react- + sdk/pull/1521 + [\#1525](https://github.com/matrix-org/matrix-react-sdk/pull/1525) + * Fix some react warnings in GroupMemberList + [\#1522](https://github.com/matrix-org/matrix-react-sdk/pull/1522) + * Fix bug with gen-i18n/js when adding new plurals + [\#1521](https://github.com/matrix-org/matrix-react-sdk/pull/1521) + * Make GroupStoreCache global for cross-package access + [\#1524](https://github.com/matrix-org/matrix-react-sdk/pull/1524) + * Add fields needed by RoomDetailList to groupRoomFromApiObject + [\#1523](https://github.com/matrix-org/matrix-react-sdk/pull/1523) + * Only show flair for groups with avatars set + [\#1519](https://github.com/matrix-org/matrix-react-sdk/pull/1519) + * Refresh group member lists after inviting users + [\#1518](https://github.com/matrix-org/matrix-react-sdk/pull/1518) + * Invalidate the user's public groups cache when changing group publicity + [\#1517](https://github.com/matrix-org/matrix-react-sdk/pull/1517) + * Make the gen-i18n script validate _t calls + [\#1515](https://github.com/matrix-org/matrix-react-sdk/pull/1515) + * Add placeholder to MyGroups page, adjust CSS classes + [\#1514](https://github.com/matrix-org/matrix-react-sdk/pull/1514) + * Rxl881/parallelshell + [\#1338](https://github.com/matrix-org/matrix-react-sdk/pull/1338) + * Run prunei18n + [\#1513](https://github.com/matrix-org/matrix-react-sdk/pull/1513) + * Update from Weblate. + [\#1512](https://github.com/matrix-org/matrix-react-sdk/pull/1512) + * Add script to prune unused translations + [\#1502](https://github.com/matrix-org/matrix-react-sdk/pull/1502) + * Fix creation of DM rooms + [\#1510](https://github.com/matrix-org/matrix-react-sdk/pull/1510) + * Group create dialog: only enter localpart + [\#1507](https://github.com/matrix-org/matrix-react-sdk/pull/1507) + * Improve MyGroups UI + [\#1509](https://github.com/matrix-org/matrix-react-sdk/pull/1509) + * Use object URLs to load Files in to images + [\#1508](https://github.com/matrix-org/matrix-react-sdk/pull/1508) + * Add clientside error for non-alphanumeric group ID + [\#1506](https://github.com/matrix-org/matrix-react-sdk/pull/1506) + * Fix invites to groups without names + [\#1505](https://github.com/matrix-org/matrix-react-sdk/pull/1505) + * Add warning when adding group rooms/users + [\#1504](https://github.com/matrix-org/matrix-react-sdk/pull/1504) + * More Groups->Communities + [\#1503](https://github.com/matrix-org/matrix-react-sdk/pull/1503) + * Groups -> Communities + [\#1501](https://github.com/matrix-org/matrix-react-sdk/pull/1501) + * Factor out Flair cache into FlairStore + [\#1500](https://github.com/matrix-org/matrix-react-sdk/pull/1500) + * Add i18n script to package.json + [\#1499](https://github.com/matrix-org/matrix-react-sdk/pull/1499) + * Make gen-i18n support 'HTML' + [\#1498](https://github.com/matrix-org/matrix-react-sdk/pull/1498) + * fix editing visuals on groupview header + [\#1497](https://github.com/matrix-org/matrix-react-sdk/pull/1497) + * Script to generate the translations base file + [\#1493](https://github.com/matrix-org/matrix-react-sdk/pull/1493) + * Update from Weblate. + [\#1495](https://github.com/matrix-org/matrix-react-sdk/pull/1495) + * Attempt to relate a group to a room when adding it + [\#1494](https://github.com/matrix-org/matrix-react-sdk/pull/1494) + * Shuffle GroupView UI + [\#1490](https://github.com/matrix-org/matrix-react-sdk/pull/1490) + * Fix bug preventing partial group profile + [\#1491](https://github.com/matrix-org/matrix-react-sdk/pull/1491) + * Don't show room IDs when picking rooms + [\#1492](https://github.com/matrix-org/matrix-react-sdk/pull/1492) + * Only show invited section if there are invited group members + [\#1489](https://github.com/matrix-org/matrix-react-sdk/pull/1489) + * Show "Invited" section in the user list + [\#1488](https://github.com/matrix-org/matrix-react-sdk/pull/1488) + * Refactor class names for an entity tile being hovered over + [\#1487](https://github.com/matrix-org/matrix-react-sdk/pull/1487) + * Modify GroupView UI + [\#1475](https://github.com/matrix-org/matrix-react-sdk/pull/1475) + * Message/event pinning + [\#1439](https://github.com/matrix-org/matrix-react-sdk/pull/1439) + * Remove duplicate declaration that breaks the build + [\#1483](https://github.com/matrix-org/matrix-react-sdk/pull/1483) + * Include magnet scheme in sanitize HTML params + [\#1301](https://github.com/matrix-org/matrix-react-sdk/pull/1301) + * Add a way to jump to a user's Read Receipt from MemberInfo + [\#1454](https://github.com/matrix-org/matrix-react-sdk/pull/1454) + * Use standard subsitution syntax in _tJsx + [\#1462](https://github.com/matrix-org/matrix-react-sdk/pull/1462) + * Don't suggest grey as a color scheme for a room + [\#1442](https://github.com/matrix-org/matrix-react-sdk/pull/1442) + * allow hiding of notification body for privacy reasons + [\#1362](https://github.com/matrix-org/matrix-react-sdk/pull/1362) + * Suggest to invite people when speaking in an empty room + [\#1466](https://github.com/matrix-org/matrix-react-sdk/pull/1466) + * Buttons to remove room/self avatar + [\#1478](https://github.com/matrix-org/matrix-react-sdk/pull/1478) + * T3chguy/fix memberlist + [\#1480](https://github.com/matrix-org/matrix-react-sdk/pull/1480) + * add option to disable BigEmoji + [\#1481](https://github.com/matrix-org/matrix-react-sdk/pull/1481) + Changes in [0.10.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.7) (2017-10-16) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.7-rc.3...v0.10.7) diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000000..d41aebad3c --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,151 @@ +# Settings Reference + +This document serves as developer documentation for using "Granular Settings". Granular Settings allow users to specify different values for a setting at particular levels of interest. For example, a user may say that in a particular room they want URL previews off, but in all other rooms they want them enabled. The `SettingsStore` helps mask the complexity of dealing with the different levels and exposes easy to use getters and setters. + + +## Levels + +Granular Settings rely on a series of known levels in order to use the correct value for the scenario. These levels, in order of prioirty, are: +* `device` - The current user's device +* `room-device` - The current user's device, but only when in a specific room +* `room-account` - The current user's account, but only when in a specific room +* `account` - The current user's account +* `room` - A specific room (setting for all members of the room) +* `config` - Values are defined by `config.json` +* `default` - The hardcoded default for the settings + +Individual settings may control which levels are appropriate for them as part of the defaults. This is often to ensure that room administrators cannot force account-only settings upon participants. + + +## Settings + +Settings are the different options a user may set or experience in the application. These are pre-defined in `src/settings/Settings.js` under the `SETTINGS` constant and have the following minimum requirements: +``` +// The ID is used to reference the setting throughout the application. This must be unique. +"theSettingId": { + // The levels this setting supports is required. In `src/settings/Settings.js` there are various pre-set arrays + // for this option - they should be used where possible to avoid copy/pasting arrays across settings. + supportedLevels: [...], + + // The default for this setting serves two purposes: It provides a value if the setting is not defined at other + // levels, and it serves to demonstrate the expected type to other developers. The value isn't enforced, but it + // should be respected throughout the code. The default may be any data type. + default: false, + + // The display name has two notations: string and object. The object notation allows for different translatable + // strings to be used for different levels, while the string notation represents the string for all levels. + + displayName: _td("Change something"), // effectively `displayName: { "default": _td("Change something") }` + displayName: { + "room": _td("Change something for participants of this room"), + + // Note: the default will be used if the level requested (such as `device`) does not have a string defined here. + "default": _td("Change something"), + } +} +``` + +### Getting values for a setting + +After importing `SettingsStore`, simply make a call to `SettingsStore.getValue`. The `roomId` parameter should always be supplied where possible, even if the setting does not have a per-room level value. This is to ensure that the value returned is best represented in the room, particularly if the setting ever gets a per-room level in the future. + +In settings pages it is often desired to have the value at a particular level instead of getting the calculated value. Call `SettingsStore.getValueAt` to get the value of a setting at a particular level, and optionally make it explicitly at that level. By default `getValueAt` will traverse the tree starting at the provided level; making it explicit means it will not go beyond the provided level. When using `getValueAt`, please be sure to use `SettingLevel` to represent the target level. + +### Setting values for a setting + +Values are defined at particular levels and should be done in a safe manner. There are two checks to perform to ensure a clean save: is the level supported and can the user actually set the value. In most cases, neither should be an issue although there are circumstances where this changes. An example of a safe call is: +```javascript +const isSupported = SettingsStore.isLevelSupported(SettingLevel.ROOM); +if (isSupported) { + const canSetValue = SettingsStore.canSetValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM); + if (canSetValue) { + SettingsStore.setValue("mySetting", "!curbf:matrix.org", SettingLevel.ROOM, newValue); + } +} +``` + +These checks may also be performed in different areas of the application to avoid the verbose example above. For instance, the component which allows changing the setting may be hidden conditionally on the above conditions. + +##### `SettingsFlag` component + +Where possible, the `SettingsFlag` component should be used to set simple "flip-a-bit" (true/false) settings. The `SettingsFlag` also supports simple radio button options, such as the theme the user would like to use. +```html + +``` + +### Getting the display name for a setting + +Simply call `SettingsStore.getDisplayName`. The appropriate display name will be returned and automatically translated for you. If a display name cannot be found, it will return `null`. + + +## Features + +Occasionally some parts of the application may be undergoing testing and are not quite production ready. These are commonly known to be behind a "labs flag". Features behind lab flags must go through the granular settings system, and look and act very much normal settings. The exception is that they must supply `isFeature: true` as part of the setting definition and should go through the helper functions on `SettingsStore`. + +### Determining if a feature is enabled + +A simple call to `SettingsStore.isFeatureEnabled` will tell you if the feature is enabled. This will perform all the required calculations to determine if the feature is enabled based upon the configuration and user selection. + +### Enabling a feature + +Features can only be enabled if the feature is in the `labs` state, otherwise this is a no-op. To find the current set of features in the `labs` state, call `SettingsStore.getLabsFeatures`. To set the value, call `SettingsStore.setFeatureEnabled`. + + +## Setting controllers + +Settings may have environmental factors that affect their value or need additional code to be called when they are modified. A setting controller is able to override the calculated value for a setting and react to changes in that setting. Controllers are not a replacement for the level handlers and should only be used to ensure the environment is kept up to date with the setting where it is otherwise not possible. An example of this is the notification settings: they can only be considered enabled if the platform supports notifications, and enabling notifications requires additional steps to actually enable notifications. + +For more information, see `src/settings/controllers/SettingController.js`. + + +## Local echo + +`SettingsStore` will perform local echo on all settings to ensure that immediately getting values does not cause a split-brain scenario. As mentioned in the "Setting values for a setting" section, the appropriate checks should be done to ensure that the user is allowed to set the value. The local echo system assumes that the user has permission and that the request will go through successfully. The local echo only takes effect until the request to save a setting has completed (either successfully or otherwise). + +```javascript +SettingsStore.setValue(...).then(() => { + // The value has actually been stored at this point. +}); +SettingsStore.getValue(...); // this will return the value set in `setValue` above. +``` + + + +# Maintainers Reference + +The granular settings system has a few complex parts to power it. This section is to document how the `SettingsStore` is supposed to work. + +### General information + +The `SettingsStore` uses the hardcoded `LEVEL_ORDER` constant to ensure that it is using the correct override procedure. The array is checked from left to right, simulating the behaviour of overriding values from the higher levels. Each level should be defined in this array, including `default`. + +Handlers (`src/settings/handlers/SettingsHandler.js`) represent a single level and are responsible for getting and setting values at that level. Handlers also provide additional information to the `SettingsStore` such as if the level is supported or if the current user may set values at the level. The `SettingsStore` will use the handler to enforce checks and manipulate settings. Handlers are also responsible for dealing with migration patterns or legacy settings for their level (for example, a setting being renamed or using a different key from other settings in the underlying store). Handlers are provided to the `SettingsStore` via the `LEVEL_HANDLERS` constant. `SettingsStore` will optimize lookups by only considering handlers that are supported on the platform. + +Local echo is achieved through `src/settings/handlers/LocalEchoWrapper.js` which acts as a wrapper around a given handler. This is automatically applied to all defined `LEVEL_HANDLERS` and proxies the calls to the wrapped handler where possible. The echo is achieved by a simple object cache stored within the class itself. The cache is invalidated immediately upon the proxied save call succeeding or failing. + +Controllers are notified of changes by the `SettingsStore`, and are given the opportunity to override values after the `SettingsStore` has deemed the value calculated. Controllers are invoked as the last possible step in the code. + +### Features + +Features automatically get considered as `disabled` if they are not listed in the `SdkConfig` or `enable_labs` is false/not set. Features are always checked against the configuration before going through the level order as they have the option of being forced-on or forced-off for the application. This is done by the `features` section and looks something like this: + +``` +"features": { + "feature_groups": "enable", + "feature_pinning": "disable", // the default + "feature_presence": "labs" +} +``` + +If `enableLabs` is true in the configuration, the default for features becomes `"labs"`. diff --git a/package.json b/package.json index 883fdae8d5..414275cccc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.10.7", + "version": "0.11.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -71,9 +71,10 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "0.8.5", + "matrix-js-sdk": "0.9.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-dom": "^15.4.0", diff --git a/src/Analytics.js b/src/Analytics.js index a82f57a144..1b4f45bc6b 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -19,7 +19,7 @@ import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; function getRedactedUrl() { - const redactedHash = window.location.hash.replace(/#\/(room|user)\/(.+)/, "#/$1/"); + const redactedHash = window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/"); // hardcoded url to make piwik happy return 'https://riot.im/app/' + redactedHash; } diff --git a/src/CallHandler.js b/src/CallHandler.js index a9539d40e1..7dbd0c899b 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -52,13 +52,13 @@ limitations under the License. */ import MatrixClientPeg from './MatrixClientPeg'; -import UserSettingsStore from './UserSettingsStore'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; import sdk from './index'; import { _t } from './languageHandler'; import Matrix from 'matrix-js-sdk'; import dis from './dispatcher'; +import SettingsStore from "./settings/SettingsStore"; global.mxCalls = { //room_id: MatrixCall @@ -246,7 +246,7 @@ function _onAction(payload) { } else if (members.length === 2) { console.log("Place %s call in %s", payload.type, payload.room_id); const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id, { - forceTURN: UserSettingsStore.getLocalSetting('webRtcForceTURN', false), + forceTURN: SettingsStore.getValue('webRtcForceTURN'), }); placeCall(call); } else { // > 2 diff --git a/src/CallMediaHandler.js b/src/CallMediaHandler.js index 839b496845..cdc5c61921 100644 --- a/src/CallMediaHandler.js +++ b/src/CallMediaHandler.js @@ -14,8 +14,8 @@ limitations under the License. */ -import UserSettingsStore from './UserSettingsStore'; import * as Matrix from 'matrix-js-sdk'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; export default { getDevices: function() { @@ -43,22 +43,20 @@ export default { }, loadDevices: function() { - // this.getDevices().then((devices) => { - const localSettings = UserSettingsStore.getLocalSettings(); - // // if deviceId is not found, automatic fallback is in spec - // // recall previously stored inputs if any - Matrix.setMatrixCallAudioInput(localSettings['webrtc_audioinput']); - Matrix.setMatrixCallVideoInput(localSettings['webrtc_videoinput']); - // }); + const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); + const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); + + Matrix.setMatrixCallAudioInput(audioDeviceId); + Matrix.setMatrixCallVideoInput(videoDeviceId); }, setAudioInput: function(deviceId) { - UserSettingsStore.setLocalSetting('webrtc_audioinput', deviceId); + SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); Matrix.setMatrixCallAudioInput(deviceId); }, setVideoInput: function(deviceId) { - UserSettingsStore.setLocalSetting('webrtc_videoinput', deviceId); + SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); Matrix.setMatrixCallVideoInput(deviceId); }, }; diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index 595f0cfe46..c4b3744575 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -49,20 +49,26 @@ export function showGroupInviteDialog(groupId) { export function showGroupAddRoomDialog(groupId) { return new Promise((resolve, reject) => { + let addRoomsPublicly = false; + const onCheckboxClicked = (e) => { + addRoomsPublicly = e.target.checked; + }; const description =
{ _t("Which rooms would you like to add to this community?") }
-
- { _t( - "Warning: any room you add to a community will be publicly "+ - "visible to anyone who knows the community ID", - ) } -
; + const checkboxContainer = ; + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, { title: _t("Add rooms to the community"), description: description, + extraNode: checkboxContainer, placeholder: _t("Room name or alias"), button: _t("Add to community"), pickerType: 'room', @@ -70,7 +76,7 @@ export function showGroupAddRoomDialog(groupId) { onFinished: (success, addrs) => { if (!success) return; - _onGroupAddRoomFinished(groupId, addrs).then(resolve, reject); + _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly).then(resolve, reject); }, }); }); @@ -106,13 +112,13 @@ function _onGroupInviteFinished(groupId, addrs) { }); } -function _onGroupAddRoomFinished(groupId, addrs) { +function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { const matrixClient = MatrixClientPeg.get(); const groupStore = GroupStoreCache.getGroupStore(matrixClient, groupId); const errorList = []; return Promise.all(addrs.map((addr) => { return groupStore - .addRoomToGroup(addr.address) + .addRoomToGroup(addr.address, addRoomsPublicly) .catch(() => { errorList.push(addr.address); }) .then(() => { const roomId = addr.address; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index b306eab23c..0c262fe89a 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -208,7 +208,7 @@ const sanitizeHtmlParams = { // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. - if (!attribs.src.startsWith('mxc://')) { + if (!attribs.src || !attribs.src.startsWith('mxc://')) { return { tagName, attribs: {}}; } attribs.src = MatrixClientPeg.get().mxcUrlToHttp( diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 4d8911f7a6..946f22537d 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -436,6 +436,10 @@ function startMatrixClient() { DMRoomMap.makeShared().start(); MatrixClientPeg.start(); + + // dispatch that we finished starting up to wire up any other bits + // of the matrix client that cannot be set prior to starting up. + dis.dispatch({action: 'client_started'}); } /* diff --git a/src/Login.js b/src/Login.js index 0eff94ce60..61a14959d8 100644 --- a/src/Login.js +++ b/src/Login.js @@ -143,6 +143,50 @@ export default class Login { Object.assign(loginParams, legacyParams); const client = this._createTemporaryClient(); + + const tryFallbackHs = (originalError) => { + const fbClient = Matrix.createClient({ + baseUrl: self._fallbackHsUrl, + idBaseUrl: this._isUrl, + }); + + return fbClient.login('m.login.password', loginParams).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._fallbackHsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((fallback_error) => { + console.log("fallback HS login failed", fallback_error); + // throw the original error + throw originalError; + }); + }; + const tryLowercaseUsername = (originalError) => { + const loginParamsLowercase = Object.assign({}, loginParams, { + user: username.toLowerCase(), + identifier: { + user: username.toLowerCase(), + }, + }); + return client.login('m.login.password', loginParamsLowercase).then(function(data) { + return Promise.resolve({ + homeserverUrl: self._hsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + deviceId: data.device_id, + accessToken: data.access_token, + }); + }).catch((fallback_error) => { + console.log("Lowercase username login failed", fallback_error); + // throw the original error + throw originalError; + }); + }; + + let originalLoginError = null; return client.login('m.login.password', loginParams).then(function(data) { return Promise.resolve({ homeserverUrl: self._hsUrl, @@ -151,28 +195,32 @@ export default class Login { deviceId: data.device_id, accessToken: data.access_token, }); - }, function(error) { + }).catch((error) => { + originalLoginError = error; if (error.httpStatus === 403) { if (self._fallbackHsUrl) { - const fbClient = Matrix.createClient({ - baseUrl: self._fallbackHsUrl, - idBaseUrl: this._isUrl, - }); - - return fbClient.login('m.login.password', loginParams).then(function(data) { - return Promise.resolve({ - homeserverUrl: self._fallbackHsUrl, - identityServerUrl: self._isUrl, - userId: data.user_id, - deviceId: data.device_id, - accessToken: data.access_token, - }); - }, function(fallback_error) { - // throw the original error - throw error; - }); + return tryFallbackHs(originalLoginError); } } + throw originalLoginError; + }).catch((error) => { + // We apparently squash case at login serverside these days: + // https://github.com/matrix-org/synapse/blob/1189be43a2479f5adf034613e8d10e3f4f452eb9/synapse/handlers/auth.py#L475 + // so this wasn't needed after all. Keeping the code around in case the + // the situation changes... + + /* + if ( + error.httpStatus === 403 && + loginParams.identifier.type === 'm.id.user' && + username.search(/[A-Z]/) > -1 + ) { + return tryLowercaseUsername(originalLoginError); + } + */ + throw originalLoginError; + }).catch((error) => { + console.log("Login failed", error); throw error; }); } diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 0c3d5b3775..7a4f0b99b0 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -93,6 +93,7 @@ class MatrixClientPeg { const opts = utils.deepCopy(this.opts); // the react sdk doesn't work without this, so don't allow opts.pendingEventOrdering = "detached"; + opts.disablePresence = true; // we do this manually try { const promise = this.matrixClient.store.startup(); diff --git a/src/Notifier.js b/src/Notifier.js index 93ef192fe0..75b698862c 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -25,6 +25,7 @@ import dis from './dispatcher'; import sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; /* * Dispatches: @@ -138,10 +139,8 @@ const Notifier = { // make sure that we persist the current setting audio_enabled setting // before changing anything - if (global.localStorage) { - if (global.localStorage.getItem('audio_notifications_enabled') === null) { - this.setAudioEnabled(this.isEnabled()); - } + if (SettingsStore.isLevelSupported(SettingLevel.DEVICE)) { + SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, this.isEnabled()); } if (enable) { @@ -149,6 +148,7 @@ const Notifier = { plaf.requestNotificationPermission().done((result) => { if (result !== 'granted') { // The permission request was dismissed or denied + // TODO: Support alternative branding in messaging const description = result === 'denied' ? _t('Riot does not have permission to send you notifications - please check your browser settings') : _t('Riot was not given permission to send notifications - please try again'); @@ -160,10 +160,6 @@ const Notifier = { return; } - if (global.localStorage) { - global.localStorage.setItem('notifications_enabled', 'true'); - } - if (callback) callback(); dis.dispatch({ action: "notifier_enabled", @@ -174,8 +170,6 @@ const Notifier = { // disabled again in the future, we will show the banner again. this.setToolbarHidden(false); } else { - if (!global.localStorage) return; - global.localStorage.setItem('notifications_enabled', 'false'); dis.dispatch({ action: "notifier_enabled", value: false, @@ -184,44 +178,24 @@ const Notifier = { }, isEnabled: function() { + return this.isPossible() && SettingsStore.getValue("notificationsEnabled"); + }, + + isPossible: function() { const plaf = PlatformPeg.get(); if (!plaf) return false; if (!plaf.supportsNotifications()) return false; if (!plaf.maySendNotifications()) return false; - if (!global.localStorage) return true; - - const enabled = global.localStorage.getItem('notifications_enabled'); - if (enabled === null) return true; - return enabled === 'true'; - }, - - setBodyEnabled: function(enable) { - if (!global.localStorage) return; - global.localStorage.setItem('notifications_body_enabled', enable ? 'true' : 'false'); + return true; // possible, but not necessarily enabled }, isBodyEnabled: function() { - if (!global.localStorage) return true; - const enabled = global.localStorage.getItem('notifications_body_enabled'); - // default to true if the popups are enabled - if (enabled === null) return this.isEnabled(); - return enabled === 'true'; + return this.isEnabled() && SettingsStore.getValue("notificationBodyEnabled"); }, - setAudioEnabled: function(enable) { - if (!global.localStorage) return; - global.localStorage.setItem('audio_notifications_enabled', - enable ? 'true' : 'false'); - }, - - isAudioEnabled: function(enable) { - if (!global.localStorage) return true; - const enabled = global.localStorage.getItem( - 'audio_notifications_enabled'); - // default to true if the popups are enabled - if (enabled === null) return this.isEnabled(); - return enabled === 'true'; + isAudioEnabled: function() { + return this.isEnabled() && SettingsStore.getValue("audioNotificationsEnabled"); }, setToolbarHidden: function(hidden, persistent = true) { @@ -238,16 +212,14 @@ const Notifier = { // update the info to localStorage for persistent settings if (persistent && global.localStorage) { - global.localStorage.setItem('notifications_hidden', hidden); + global.localStorage.setItem("notifications_hidden", hidden); } }, isToolbarHidden: function() { // Check localStorage for any such meta data if (global.localStorage) { - if (global.localStorage.getItem('notifications_hidden') === 'true') { - return true; - } + return global.localStorage.getItem("notifications_hidden") === "true"; } return this.toolbarHidden; diff --git a/src/Presence.js b/src/Presence.js index fab518e1cb..2652c64c96 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -56,13 +56,27 @@ class Presence { return this.state; } + /** + * Get the current status message. + * @returns {String} the status message, may be null + */ + getStatusMessage() { + return this.statusMessage; + } + /** * Set the presence state. * If the state has changed, the Home Server will be notified. * @param {string} newState the new presence state (see PRESENCE enum) + * @param {String} statusMessage an optional status message for the presence + * @param {boolean} maintain true to have this status maintained by this tracker */ - setState(newState) { - if (newState === this.state) { + setState(newState, statusMessage=null, maintain=false) { + if (this.maintain) { + // Don't update presence if we're maintaining a particular status + return; + } + if (newState === this.state && statusMessage === this.statusMessage) { return; } if (PRESENCE_STATES.indexOf(newState) === -1) { @@ -72,21 +86,37 @@ class Presence { return; } const old_state = this.state; + const old_message = this.statusMessage; this.state = newState; + this.statusMessage = statusMessage; + this.maintain = maintain; if (MatrixClientPeg.get().isGuest()) { return; // don't try to set presence when a guest; it won't work. } + const updateContent = { + presence: this.state, + status_msg: this.statusMessage ? this.statusMessage : '', + }; + const self = this; - MatrixClientPeg.get().setPresence(this.state).done(function() { + MatrixClientPeg.get().setPresence(updateContent).done(function() { console.log("Presence: %s", newState); + + // We have to dispatch because the js-sdk is unreliable at telling us about our own presence + dis.dispatch({action: "self_presence_updated", statusInfo: updateContent}); }, function(err) { console.error("Failed to set presence: %s", err); self.state = old_state; + self.statusMessage = old_message; }); } + stopMaintainingStatus() { + this.maintain = false; + } + /** * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. * @private @@ -95,7 +125,8 @@ class Presence { this.setState("unavailable"); } - _onUserActivity() { + _onUserActivity(payload) { + if (payload.action === "sync_state" || payload.action === "self_presence_updated") return; this._resetTimer(); } diff --git a/src/Roles.js b/src/Roles.js index 83d8192c67..438b6c1236 100644 --- a/src/Roles.js +++ b/src/Roles.js @@ -15,19 +15,20 @@ limitations under the License. */ import { _t } from './languageHandler'; -export function levelRoleMap() { +export function levelRoleMap(usersDefault) { return { undefined: _t('Default'), - 0: _t('User'), + 0: _t('Restricted'), + [usersDefault]: _t('Default'), 50: _t('Moderator'), 100: _t('Admin'), }; } -export function textualPowerLevel(level, userDefault) { - const LEVEL_ROLE_MAP = this.levelRoleMap(); +export function textualPowerLevel(level, usersDefault) { + const LEVEL_ROLE_MAP = this.levelRoleMap(usersDefault); if (LEVEL_ROLE_MAP[level]) { - return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${userDefault})`); + return LEVEL_ROLE_MAP[level] + (level !== undefined ? ` (${level})` : ` (${usersDefault})`); } else { return level; } diff --git a/src/SdkConfig.js b/src/SdkConfig.js index 48ebf011f2..8df725a913 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -26,7 +26,7 @@ const DEFAULTS = { class SdkConfig { static get() { - return global.mxReactSdkConfig; + return global.mxReactSdkConfig || {}; } static put(cfg) { diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 82665cc2f3..344bac1ddb 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -20,6 +20,7 @@ import Tinter from "./Tinter"; import sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; +import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; class Command { @@ -97,9 +98,7 @@ const commands = { colorScheme.secondary_color = matches[4]; } return success( - MatrixClientPeg.get().setRoomAccountData( - roomId, "org.matrix.room.color_scheme", colorScheme, - ), + SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), ); } } diff --git a/src/Tinter.js b/src/Tinter.js index 6b23df8c9b..916c8b18d8 100644 --- a/src/Tinter.js +++ b/src/Tinter.js @@ -1,5 +1,6 @@ /* Copyright 2015 OpenMarket Ltd +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. @@ -14,148 +15,122 @@ See the License for the specific language governing permissions and limitations under the License. */ -// FIXME: these vars should be bundled up and attached to -// module.exports otherwise this will break when included by both -// react-sdk and apps layered on top. - const DEBUG = 0; -// The colour keys to be replaced as referred to in CSS -const keyRgb = [ - "rgb(118, 207, 166)", // Vector Green - "rgb(234, 245, 240)", // Vector Light Green - "rgb(211, 239, 225)", // BottomLeftMenu overlay (20% Vector Green) -]; +// utility to turn #rrggbb or rgb(r,g,b) into [red,green,blue] +function colorToRgb(color) { + if (!color) { + return [0, 0, 0]; + } -// Some algebra workings for calculating the tint % of Vector Green & Light Green -// x * 118 + (1 - x) * 255 = 234 -// x * 118 + 255 - 255 * x = 234 -// x * 118 - x * 255 = 234 - 255 -// (255 - 118) x = 255 - 234 -// x = (255 - 234) / (255 - 118) = 0.16 - -// The colour keys to be replaced as referred to in SVGs -const keyHex = [ - "#76CFA6", // Vector Green - "#EAF5F0", // Vector Light Green - "#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on Vector Light Green) - "#FFFFFF", // white highlights of the SVGs (for switching to dark theme) -]; - -// cache of our replacement colours -// defaults to our keys. -const colors = [ - keyHex[0], - keyHex[1], - keyHex[2], - keyHex[3], -]; - -const cssFixups = [ - // { - // style: a style object that should be fixed up taken from a stylesheet - // attr: name of the attribute to be clobbered, e.g. 'color' - // index: ordinal of primary, secondary or tertiary - // } -]; - -// CSS attributes to be fixed up -const cssAttrs = [ - "color", - "backgroundColor", - "borderColor", - "borderTopColor", - "borderBottomColor", - "borderLeftColor", -]; - -const svgAttrs = [ - "fill", - "stroke", -]; - -let cached = false; - -function calcCssFixups() { - if (DEBUG) console.log("calcSvgFixups start"); - 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 - - // 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? - - if (ss.href && !ss.href.match(/\/bundle.*\.css$/)) continue; - - if (!ss.cssRules) continue; - for (let j = 0; j < ss.cssRules.length; j++) { - const rule = ss.cssRules[j]; - if (!rule.style) continue; - for (let k = 0; k < cssAttrs.length; k++) { - const attr = cssAttrs[k]; - for (let l = 0; l < keyRgb.length; l++) { - if (rule.style[attr] === keyRgb[l]) { - cssFixups.push({ - style: rule.style, - attr: attr, - index: l, - }); - } - } - } + if (color[0] === '#') { + color = color.slice(1); + if (color.length === 3) { + color = color[0] + color[0] + + color[1] + color[1] + + color[2] + color[2]; + } + const val = parseInt(color, 16); + const r = (val >> 16) & 255; + const g = (val >> 8) & 255; + const b = val & 255; + return [r, g, b]; + } else { + const match = color.match(/rgb\((.*?),(.*?),(.*?)\)/); + if (match) { + return [ + parseInt(match[1]), + parseInt(match[2]), + parseInt(match[3]), + ]; } } - if (DEBUG) console.log("calcSvgFixups end"); + return [0, 0, 0]; } -function applyCssFixups() { - if (DEBUG) console.log("applyCssFixups start"); - for (let i = 0; i < cssFixups.length; i++) { - const cssFixup = cssFixups[i]; - cssFixup.style[cssFixup.attr] = colors[cssFixup.index]; - } - if (DEBUG) console.log("applyCssFixups end"); -} - -function hexToRgb(color) { - if (color[0] === '#') color = color.slice(1); - if (color.length === 3) { - color = color[0] + color[0] + - color[1] + color[1] + - color[2] + color[2]; - } - const val = parseInt(color, 16); - const r = (val >> 16) & 255; - const g = (val >> 8) & 255; - const b = val & 255; - return [r, g, b]; -} - -function rgbToHex(rgb) { +// utility to turn [red,green,blue] into #rrggbb +function rgbToColor(rgb) { const val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; return '#' + (0x1000000 + val).toString(16).slice(1); } -// List of functions to call when the tint changes. -const tintables = []; +class Tinter { + constructor() { + // The default colour keys to be replaced as referred to in CSS + // (should be overridden by .mx_theme_accentColor and .mx_theme_secondaryAccentColor) + this.keyRgb = [ + "rgb(118, 207, 166)", // Vector Green + "rgb(234, 245, 240)", // Vector Light Green + "rgb(211, 239, 225)", // roomsublist-label-bg-color (20% Green overlaid on Light Green) + ]; + + // Some algebra workings for calculating the tint % of Vector Green & Light Green + // x * 118 + (1 - x) * 255 = 234 + // x * 118 + 255 - 255 * x = 234 + // x * 118 - x * 255 = 234 - 255 + // (255 - 118) x = 255 - 234 + // x = (255 - 234) / (255 - 118) = 0.16 + + // The colour keys to be replaced as referred to in SVGs + this.keyHex = [ + "#76CFA6", // Vector Green + "#EAF5F0", // Vector Light Green + "#D3EFE1", // roomsublist-label-bg-color (20% Green overlaid on Light Green) + "#FFFFFF", // white highlights of the SVGs (for switching to dark theme) + ]; + + // track the replacement colours actually being used + // defaults to our keys. + this.colors = [ + this.keyHex[0], + this.keyHex[1], + this.keyHex[2], + this.keyHex[3], + ]; + + // track the most current tint request inputs (which may differ from the + // end result stored in this.colors + this.currentTint = [ + undefined, + undefined, + undefined, + undefined, + ]; + + this.cssFixups = [ + // { theme: { + // style: a style object that should be fixed up taken from a stylesheet + // attr: name of the attribute to be clobbered, e.g. 'color' + // index: ordinal of primary, secondary or tertiary + // }, + // } + ]; + + // CSS attributes to be fixed up + this.cssAttrs = [ + "color", + "backgroundColor", + "borderColor", + "borderTopColor", + "borderBottomColor", + "borderLeftColor", + ]; + + this.svgAttrs = [ + "fill", + "stroke", + ]; + + // List of functions to call when the tint changes. + this.tintables = []; + + // the currently loaded theme (if any) + this.theme = undefined; + + // whether to force a tint (e.g. after changing theme) + this.forceTint = false; + } -module.exports = { /** * Register a callback to fire when the tint changes. * This is used to rewrite the tintable SVGs with the new tint. @@ -167,79 +142,225 @@ module.exports = { * * @param {Function} tintable Function to call when the tint changes. */ - registerTintable: function(tintable) { - tintables.push(tintable); - }, + registerTintable(tintable) { + this.tintables.push(tintable); + } - tint: function(primaryColor, secondaryColor, tertiaryColor) { - if (!cached) { - calcCssFixups(); - cached = true; + getKeyRgb() { + return this.keyRgb; + } + + tint(primaryColor, secondaryColor, tertiaryColor) { + this.currentTint[0] = primaryColor; + this.currentTint[1] = secondaryColor; + this.currentTint[2] = tertiaryColor; + + this.calcCssFixups(); + + if (DEBUG) { + console.log("Tinter.tint(" + primaryColor + ", " + + secondaryColor + ", " + + tertiaryColor + ")"); } if (!primaryColor) { - primaryColor = "#76CFA6"; // Vector green - secondaryColor = "#EAF5F0"; // Vector light green + primaryColor = this.keyRgb[0]; + secondaryColor = this.keyRgb[1]; + tertiaryColor = this.keyRgb[2]; } if (!secondaryColor) { const x = 0.16; // average weighting factor calculated from vector green & light green - const rgb = hexToRgb(primaryColor); + const rgb = colorToRgb(primaryColor); rgb[0] = x * rgb[0] + (1 - x) * 255; rgb[1] = x * rgb[1] + (1 - x) * 255; rgb[2] = x * rgb[2] + (1 - x) * 255; - secondaryColor = rgbToHex(rgb); + secondaryColor = rgbToColor(rgb); } if (!tertiaryColor) { const x = 0.19; - const rgb1 = hexToRgb(primaryColor); - const rgb2 = hexToRgb(secondaryColor); + const rgb1 = colorToRgb(primaryColor); + const rgb2 = colorToRgb(secondaryColor); rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0]; rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1]; rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2]; - tertiaryColor = rgbToHex(rgb1); + tertiaryColor = rgbToColor(rgb1); } - if (colors[0] === primaryColor && - colors[1] === secondaryColor && - colors[2] === tertiaryColor) { + if (this.forceTint == false && + this.colors[0] === primaryColor && + this.colors[1] === secondaryColor && + this.colors[2] === tertiaryColor) { return; } - colors[0] = primaryColor; - colors[1] = secondaryColor; - colors[2] = tertiaryColor; + this.forceTint = false; - if (DEBUG) console.log("Tinter.tint"); + this.colors[0] = primaryColor; + this.colors[1] = secondaryColor; + this.colors[2] = tertiaryColor; + + if (DEBUG) { + console.log("Tinter.tint final: (" + primaryColor + ", " + + secondaryColor + ", " + + tertiaryColor + ")"); + } // go through manually fixing up the stylesheets. - applyCssFixups(); + this.applyCssFixups(); // tell all the SVGs to go fix themselves up // we don't do this as a dispatch otherwise it will visually lag - tintables.forEach(function(tintable) { + this.tintables.forEach(function(tintable) { tintable(); }); - }, + } + + tintSvgWhite(whiteColor) { + this.currentTint[3] = whiteColor; - tintSvgWhite: function(whiteColor) { if (!whiteColor) { - whiteColor = colors[3]; + whiteColor = this.colors[3]; } - if (colors[3] === whiteColor) { + if (this.colors[3] === whiteColor) { return; } - colors[3] = whiteColor; - tintables.forEach(function(tintable) { + this.colors[3] = whiteColor; + this.tintables.forEach(function(tintable) { tintable(); }); - }, + } + + setTheme(theme) { + console.trace("setTheme " + theme); + this.theme = theme; + + // update keyRgb from the current theme CSS itself, if it defines it + if (document.getElementById('mx_theme_accentColor')) { + this.keyRgb[0] = window.getComputedStyle( + document.getElementById('mx_theme_accentColor')).color; + } + if (document.getElementById('mx_theme_secondaryAccentColor')) { + this.keyRgb[1] = window.getComputedStyle( + document.getElementById('mx_theme_secondaryAccentColor')).color; + } + if (document.getElementById('mx_theme_tertiaryAccentColor')) { + this.keyRgb[2] = window.getComputedStyle( + document.getElementById('mx_theme_tertiaryAccentColor')).color; + } + + this.calcCssFixups(); + this.forceTint = true; + + this.tint(this.currentTint[0], this.currentTint[1], this.currentTint[2]); + + if (theme === 'dark') { + // abuse the tinter to change all the SVG's #fff to #2d2d2d + // XXX: obviously this shouldn't be hardcoded here. + this.tintSvgWhite('#2d2d2d'); + } else { + this.tintSvgWhite('#ffffff'); + } + } + + calcCssFixups() { + // cache our fixups + if (this.cssFixups[this.theme]) return; + + if (DEBUG) { + console.debug("calcCssFixups start for " + this.theme + " (checking " + + document.styleSheets.length + + " stylesheets)"); + } + + this.cssFixups[this.theme] = []; + + 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 + + // 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 + + 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); + + 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, + }); + } + } + } + } + } + if (DEBUG) { + console.log("calcCssFixups end (" + + this.cssFixups[this.theme].length + + " fixups)"); + } + } + + applyCssFixups() { + if (DEBUG) { + console.log("applyCssFixups start (" + + this.cssFixups[this.theme].length + + " fixups)"); + } + for (let i = 0; i < this.cssFixups[this.theme].length; i++) { + const cssFixup = this.cssFixups[this.theme][i]; + try { + cssFixup.style[cssFixup.attr] = this.colors[cssFixup.index]; + } catch (e) { + // Firefox Quantum explodes if you manually edit the CSS in the + // inspector and then try to do a tint, as apparently all the + // fixups are then stale. + console.error("Failed to apply cssFixup in Tinter! ", e.name); + } + } + if (DEBUG) console.log("applyCssFixups end"); + } // XXX: we could just move this all into TintableSvg, but as it's so similar // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) // keeping it here for now. - calcSvgFixups: function(svgs) { + calcSvgFixups(svgs) { // go through manually fixing up SVG colours. // we could do this by stylesheets, but keeping the stylesheets // updated would be a PITA, so just brute-force search for the @@ -248,7 +369,7 @@ module.exports = { if (DEBUG) console.log("calcSvgFixups start for " + svgs); const fixups = []; for (let i = 0; i < svgs.length; i++) { - var svgDoc; + let svgDoc; try { svgDoc = svgs[i].contentDocument; } catch(e) { @@ -259,16 +380,17 @@ module.exports = { if (e.stack) { msg += ' | stack: ' + e.stack; } - console.error(e); + console.error(msg); } if (!svgDoc) continue; const tags = svgDoc.getElementsByTagName("*"); for (let j = 0; j < tags.length; j++) { const tag = tags[j]; - for (let k = 0; k < svgAttrs.length; k++) { - const attr = svgAttrs[k]; - for (let l = 0; l < keyHex.length; l++) { - if (tag.getAttribute(attr) && tag.getAttribute(attr).toUpperCase() === keyHex[l]) { + for (let k = 0; k < this.svgAttrs.length; k++) { + const attr = this.svgAttrs[k]; + for (let l = 0; l < this.keyHex.length; l++) { + if (tag.getAttribute(attr) && + tag.getAttribute(attr).toUpperCase() === this.keyHex[l]) { fixups.push({ node: tag, attr: attr, @@ -282,14 +404,19 @@ module.exports = { if (DEBUG) console.log("calcSvgFixups end"); return fixups; - }, + } - applySvgFixups: function(fixups) { + applySvgFixups(fixups) { if (DEBUG) console.log("applySvgFixups start for " + fixups); for (let i = 0; i < fixups.length; i++) { const svgFixup = fixups[i]; - svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]); + svgFixup.node.setAttribute(svgFixup.attr, this.colors[svgFixup.index]); } if (DEBUG) console.log("applySvgFixups end"); - }, -}; + } +} + +if (global.singletonTinter === undefined) { + global.singletonTinter = new Tinter(); +} +export default global.singletonTinter; diff --git a/src/Unread.js b/src/Unread.js index 20e876ad88..383b5c2e5a 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -15,7 +15,6 @@ limitations under the License. */ const MatrixClientPeg = require('./MatrixClientPeg'); -import UserSettingsStore from './UserSettingsStore'; import shouldHideEvent from './shouldHideEvent'; const sdk = require('./index'); @@ -64,7 +63,6 @@ module.exports = { // we have and the read receipt. We could fetch more history to try & find out, // but currently we just guess. - const syncedSettings = UserSettingsStore.getSyncedSettings(); // Loop through messages, starting with the most recent... for (let i = room.timeline.length - 1; i >= 0; --i) { const ev = room.timeline[i]; @@ -74,7 +72,7 @@ module.exports = { // that counts and we can stop looking because the user's read // this and everything before. return false; - } else if (!shouldHideEvent(ev, syncedSettings) && this.eventTriggersUnreadCount(ev)) { + } else if (!shouldHideEvent(ev) && this.eventTriggersUnreadCount(ev)) { // We've found a message that counts before we hit // the read marker, so this room is definitely unread. return true; diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index ce39939bc0..5d2af3715f 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -17,58 +17,11 @@ limitations under the License. import Promise from 'bluebird'; import MatrixClientPeg from './MatrixClientPeg'; -import Notifier from './Notifier'; -import { _t, _td } from './languageHandler'; -import SdkConfig from './SdkConfig'; /* * TODO: Make things use this. This is all WIP - see UserSettings.js for usage. */ - -const FEATURES = [ - { - id: 'feature_groups', - name: _td("Communities"), - }, - { - id: 'feature_pinning', - name: _td("Message Pinning"), - }, -]; - export default { - getLabsFeatures() { - const featuresConfig = SdkConfig.get()['features'] || {}; - - // The old flag: honoured for backwards compatibility - const enableLabs = SdkConfig.get()['enableLabs']; - - let labsFeatures; - if (enableLabs) { - labsFeatures = FEATURES; - } else { - labsFeatures = FEATURES.filter((f) => { - const sdkConfigValue = featuresConfig[f.id]; - if (sdkConfigValue === 'labs') { - return true; - } - }); - } - return labsFeatures.map((f) => { - return f.id; - }); - }, - - translatedNameForFeature(featureId) { - const feature = FEATURES.filter((f) => { - return f.id === featureId; - })[0]; - - if (feature === undefined) return null; - - return _t(feature.name); - }, - loadProfileInfo: function() { const cli = MatrixClientPeg.get(); return cli.getProfileInfo(cli.credentials.userId); @@ -91,36 +44,6 @@ export default { // TODO }, - getEnableNotifications: function() { - return Notifier.isEnabled(); - }, - - setEnableNotifications: function(enable) { - if (!Notifier.supportsDesktopNotifications()) { - return; - } - Notifier.setEnabled(enable); - }, - - getEnableNotificationBody: function() { - return Notifier.isBodyEnabled(); - }, - - setEnableNotificationBody: function(enable) { - if (!Notifier.supportsDesktopNotifications()) { - return; - } - Notifier.setBodyEnabled(enable); - }, - - getEnableAudioNotifications: function() { - return Notifier.isAudioEnabled(); - }, - - setEnableAudioNotifications: function(enable) { - Notifier.setAudioEnabled(enable); - }, - changePassword: function(oldPassword, newPassword) { const cli = MatrixClientPeg.get(); @@ -167,83 +90,4 @@ export default { append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address }); }, - - getUrlPreviewsDisabled: function() { - const event = MatrixClientPeg.get().getAccountData('org.matrix.preview_urls'); - return (event && event.getContent().disable); - }, - - setUrlPreviewsDisabled: function(disabled) { - // FIXME: handle errors - return MatrixClientPeg.get().setAccountData('org.matrix.preview_urls', { - disable: disabled, - }); - }, - - getSyncedSettings: function() { - const event = MatrixClientPeg.get().getAccountData('im.vector.web.settings'); - return event ? event.getContent() : {}; - }, - - getSyncedSetting: function(type, defaultValue = null) { - const settings = this.getSyncedSettings(); - return settings.hasOwnProperty(type) ? settings[type] : defaultValue; - }, - - setSyncedSetting: function(type, value) { - const settings = this.getSyncedSettings(); - settings[type] = value; - // FIXME: handle errors - return MatrixClientPeg.get().setAccountData('im.vector.web.settings', settings); - }, - - getLocalSettings: function() { - const localSettingsString = localStorage.getItem('mx_local_settings') || '{}'; - return JSON.parse(localSettingsString); - }, - - getLocalSetting: function(type, defaultValue = null) { - const settings = this.getLocalSettings(); - return settings.hasOwnProperty(type) ? settings[type] : defaultValue; - }, - - setLocalSetting: function(type, value) { - const settings = this.getLocalSettings(); - settings[type] = value; - // FIXME: handle errors - localStorage.setItem('mx_local_settings', JSON.stringify(settings)); - }, - - isFeatureEnabled: function(featureId: string): boolean { - const featuresConfig = SdkConfig.get()['features']; - - // The old flag: honoured for backwards compatibility - const enableLabs = SdkConfig.get()['enableLabs']; - - let sdkConfigValue = enableLabs ? 'labs' : 'disable'; - if (featuresConfig && featuresConfig[featureId] !== undefined) { - sdkConfigValue = featuresConfig[featureId]; - } - - if (sdkConfigValue === 'enable') { - return true; - } else if (sdkConfigValue === 'disable') { - return false; - } else if (sdkConfigValue === 'labs') { - if (!MatrixClientPeg.get().isGuest()) { - // Make it explicit that guests get the defaults (although they shouldn't - // have been able to ever toggle the flags anyway) - const userValue = localStorage.getItem(`mx_labs_feature_${featureId}`); - return userValue === 'true'; - } - return false; - } else { - console.warn(`Unknown features config for ${featureId}: ${sdkConfigValue}`); - return false; - } - }, - - setFeatureEnabled: function(featureId: string, enabled: boolean) { - localStorage.setItem(`mx_labs_feature_${featureId}`, enabled); - }, }; diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index 6bea2cbb92..0edad8d4a5 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -68,10 +68,8 @@ module.exports = { const names = whoIsTyping.map(function(m) { return m.name; }); - if (othersCount==1) { - return _t('%(names)s and one other are typing', {names: names.slice(0, limit - 1).join(', ')}); - } else if (othersCount>1) { - return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount}); + if (othersCount>=1) { + return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount}); } else { const lastPerson = names.pop(); return _t('%(names)s and %(lastPerson)s are typing', {names: names.join(', '), lastPerson: lastPerson}); diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 4c7d039da4..c93ae4fb2a 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +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. @@ -28,6 +29,10 @@ export default class AutocompleteProvider { } } + destroy() { + // stub + } + /** * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. */ diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 5b10110f04..3d30363d9f 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -1,5 +1,6 @@ /* Copyright 2016 Aviral Dasgupta +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. @@ -22,6 +23,7 @@ import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; +import NotifProvider from './NotifProvider'; import Promise from 'bluebird'; export type SelectionRange = { @@ -43,43 +45,59 @@ const PROVIDERS = [ UserProvider, RoomProvider, EmojiProvider, + NotifProvider, CommandProvider, DuckDuckGoProvider, -].map((completer) => completer.getInstance()); +]; // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; -export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { - /* Note: That this waits for all providers to return is *intentional* - otherwise, we run into a condition where new completions are displayed - while the user is interacting with the list, which makes it difficult - to predict whether an action will actually do what is intended - */ - const completionsList = await Promise.all( - // Array of inspections of promises that might timeout. Instead of allowing a - // single timeout to reject the Promise.all, reflect each one and once they've all - // settled, filter for the fulfilled ones - PROVIDERS.map((provider) => { - return provider - .getCompletions(query, selection, force) - .timeout(PROVIDER_COMPLETION_TIMEOUT) - .reflect(); - }), - ); +export default class Autocompleter { + constructor(room) { + this.room = room; + this.providers = PROVIDERS.map((p) => { + return new p(room); + }); + } - return completionsList.filter( - (inspection) => inspection.isFulfilled(), - ).map((completionsState, i) => { - return { - completions: completionsState.value(), - provider: PROVIDERS[i], + destroy() { + this.providers.forEach((p) => { + p.destroy(); + }); + } - /* the currently matched "command" the completer tried to complete - * we pass this through so that Autocomplete can figure out when to - * re-show itself once hidden. - */ - command: PROVIDERS[i].getCurrentCommand(query, selection, force), - }; - }); + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { + /* Note: This intentionally waits for all providers to return, + otherwise, we run into a condition where new completions are displayed + while the user is interacting with the list, which makes it difficult + to predict whether an action will actually do what is intended + */ + const completionsList = await Promise.all( + // Array of inspections of promises that might timeout. Instead of allowing a + // single timeout to reject the Promise.all, reflect each one and once they've all + // settled, filter for the fulfilled ones + this.providers.map((provider) => { + return provider + .getCompletions(query, selection, force) + .timeout(PROVIDER_COMPLETION_TIMEOUT) + .reflect(); + }), + ); + + return completionsList.filter( + (inspection) => inspection.isFulfilled(), + ).map((completionsState, i) => { + return { + completions: completionsState.value(), + provider: this.providers[i], + + /* the currently matched "command" the completer tried to complete + * we pass this through so that Autocomplete can figure out when to + * re-show itself once hidden. + */ + command: this.providers[i].getCurrentCommand(query, selection, force), + }; + }); + } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index e85457e6aa..d47f1a161a 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +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. @@ -109,8 +110,6 @@ const COMMANDS = [ const COMMAND_RE = /(^\/\w*)/g; -let instance = null; - export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); @@ -142,12 +141,6 @@ export default class CommandProvider extends AutocompleteProvider { return '*️⃣ ' + _t('Commands'); } - static getInstance(): CommandProvider { - if (instance === null) instance = new CommandProvider(); - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index b2e85c4668..68d4915f56 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +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. @@ -25,8 +26,6 @@ import {TextualCompletion} from './Components'; const DDG_REGEX = /\/ddg\s+(.+)$/g; const REFERRER = 'vector'; -let instance = null; - export default class DuckDuckGoProvider extends AutocompleteProvider { constructor() { super(DDG_REGEX); @@ -96,13 +95,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { return '🔍 ' + _t('Results from DuckDuckGo'); } - static getInstance(): DuckDuckGoProvider { - if (instance == null) { - instance = new DuckDuckGoProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index a5b80e3b0e..f4e576ea0f 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +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. @@ -25,7 +26,7 @@ import {PillCompletion} from './Components'; import type {SelectionRange, Completion} from './Autocompleter'; import _uniq from 'lodash/uniq'; import _sortBy from 'lodash/sortBy'; -import UserSettingsStore from '../UserSettingsStore'; +import SettingsStore from "../settings/SettingsStore"; import EmojiData from '../stripped-emoji.json'; @@ -70,8 +71,6 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor }; }); -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -97,7 +96,7 @@ export default class EmojiProvider extends AutocompleteProvider { } async getCompletions(query: string, selection: SelectionRange) { - if (UserSettingsStore.getSyncedSetting("MessageComposerInput.dontSuggestEmoji")) { + if (SettingsStore.getValue("MessageComposerInput.dontSuggestEmoji")) { return []; // don't give any suggestions if the user doesn't want them } @@ -151,11 +150,6 @@ export default class EmojiProvider extends AutocompleteProvider { return '😃 ' + _t('Emoji'); } - static getInstance() { - if (instance == null) {instance = new EmojiProvider();} - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/NotifProvider.js b/src/autocomplete/NotifProvider.js new file mode 100644 index 0000000000..b7ac645525 --- /dev/null +++ b/src/autocomplete/NotifProvider.js @@ -0,0 +1,62 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import AutocompleteProvider from './AutocompleteProvider'; +import { _t } from '../languageHandler'; +import MatrixClientPeg from '../MatrixClientPeg'; +import {PillCompletion} from './Components'; +import sdk from '../index'; + +const AT_ROOM_REGEX = /@\S*/g; + +export default class NotifProvider extends AutocompleteProvider { + constructor(room) { + super(AT_ROOM_REGEX); + this.room = room; + } + + async getCompletions(query: string, selection: {start: number, end: number}, force = false) { + const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); + + const client = MatrixClientPeg.get(); + + if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return []; + + const {command, range} = this.getCurrentCommand(query, selection, force); + if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) { + return [{ + completion: '@room', + suffix: ' ', + component: ( + } title="@room" description={_t("Notify the whole room")} /> + ), + range, + }]; + } + return []; + } + + getName() { + return '❗️ ' + _t('Room Notification'); + } + + renderCompletions(completions: [React.Component]): ?React.Component { + return
+ { completions } +
; + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index cc04f54dda..1e1928a1ee 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +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. @@ -27,8 +28,6 @@ import _sortBy from 'lodash/sortBy'; const ROOM_REGEX = /(?=#)(\S*)/g; -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -96,14 +95,6 @@ export default class RoomProvider extends AutocompleteProvider { return '💬 ' + _t('Rooms'); } - static getInstance() { - if (instance == null) { - instance = new RoomProvider(); - } - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 296399c06c..8b43964b1a 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -2,6 +2,7 @@ /* Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd +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. @@ -30,20 +31,55 @@ import type {Room, RoomMember} from 'matrix-js-sdk'; const USER_REGEX = /@\S*/g; -let instance = null; - export default class UserProvider extends AutocompleteProvider { users: Array = null; room: Room = null; - constructor() { + constructor(room) { super(USER_REGEX, { keys: ['name'], }); + this.room = room; this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], shouldMatchPrefix: true, }); + + this._onRoomTimelineBound = this._onRoomTimeline.bind(this); + this._onRoomStateMemberBound = this._onRoomStateMember.bind(this); + + MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound); + } + + destroy() { + MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound); + } + + _onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + if (!room) return; + if (removed) return; + if (room.roomId !== this.room.roomId) return; + + // ignore events from filtered timelines + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + + // ignore anything but real-time updates at the end of the room: + // updates from pagination will happen when the paginate completes. + if (toStartOfTimeline || !data || !data.liveEvent) return; + + this.onUserSpoke(ev.sender); + } + + _onRoomStateMember(ev, state, member) { + // ignore members in other rooms + if (member.roomId !== this.room.roomId) { + return; + } + + // blow away the users cache + this.users = null; } async getCompletions(query: string, selection: {start: number, end: number}, force = false) { @@ -86,11 +122,6 @@ export default class UserProvider extends AutocompleteProvider { return '👥 ' + _t('Users'); } - setUserListFromRoom(room: Room) { - this.room = room; - this.users = null; - } - _makeUsers() { const events = this.room.getLiveTimeline().getEvents(); const lastSpoken = {}; @@ -123,13 +154,6 @@ export default class UserProvider extends AutocompleteProvider { this.matcher.setObjects(this.users); } - static getInstance(): UserProvider { - if (instance == null) { - instance = new UserProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index c3ad7f9cd1..3c2308e6a7 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -33,6 +33,7 @@ module.exports = { menuHeight: React.PropTypes.number, chevronOffset: React.PropTypes.number, menuColour: React.PropTypes.string, + chevronFace: React.PropTypes.string, // top, bottom, left, right }, getOrCreateContainer: function() { @@ -58,12 +59,30 @@ module.exports = { } }; - const position = { - top: props.top, - }; + const position = {}; + let chevronFace = null; + + if (props.top) { + position.top = props.top; + } else { + position.bottom = props.bottom; + } + + if (props.left) { + position.left = props.left; + chevronFace = 'left'; + } else { + position.right = props.right; + chevronFace = 'right'; + } const chevronOffset = {}; - if (props.chevronOffset) { + if (props.chevronFace) { + chevronFace = props.chevronFace; + } + if (chevronFace === 'top' || chevronFace === 'bottom') { + chevronOffset.left = props.chevronOffset; + } else { chevronOffset.top = props.chevronOffset; } @@ -74,28 +93,27 @@ module.exports = { .mx_ContextualMenu_chevron_left:after { border-right-color: ${props.menuColour}; } - .mx_ContextualMenu_chevron_right:after { border-left-color: ${props.menuColour}; } + .mx_ContextualMenu_chevron_top:after { + border-left-color: ${props.menuColour}; + } + .mx_ContextualMenu_chevron_bottom:after { + border-left-color: ${props.menuColour}; + } `; } - let chevron = null; - if (props.left) { - chevron =
; - position.left = props.left; - } else { - chevron =
; - position.right = props.right; - } - + const chevron =
; const className = 'mx_ContextualMenu_wrapper'; const menuClasses = classNames({ 'mx_ContextualMenu': true, - 'mx_ContextualMenu_left': props.left, - 'mx_ContextualMenu_right': !props.left, + 'mx_ContextualMenu_left': chevronFace === 'left', + 'mx_ContextualMenu_right': chevronFace === 'right', + 'mx_ContextualMenu_top': chevronFace === 'top', + 'mx_ContextualMenu_bottom': chevronFace === 'bottom', }); const menuStyle = {}; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 1564efd8d3..1b5ebb6b36 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -22,7 +22,7 @@ import MatrixClientPeg from '../../MatrixClientPeg'; import sdk from '../../index'; import dis from '../../dispatcher'; import { sanitizedHtmlNode } from '../../HtmlUtils'; -import { _t } from '../../languageHandler'; +import { _t, _td, _tJsx } from '../../languageHandler'; import AccessibleButton from '../views/elements/AccessibleButton'; import Modal from '../../Modal'; import classnames from 'classnames'; @@ -32,6 +32,17 @@ import GroupStore from '../../stores/GroupStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GeminiScrollbar from 'react-gemini-scrollbar'; +const LONG_DESC_PLACEHOLDER = _td( +`

HTML for your community's page

+

+ Use the long description to introduce new members to the community, or distribute + some important links +

+

+ You can even use 'img' tags +

+`); + const RoomSummaryType = PropTypes.shape({ room_id: PropTypes.string.isRequired, profile: PropTypes.shape({ @@ -392,6 +403,8 @@ export default React.createClass({ propTypes: { groupId: PropTypes.string.isRequired, + // Whether this is the first time the group admin is viewing the group + groupIsNew: PropTypes.bool, }, childContextTypes: { @@ -407,24 +420,30 @@ export default React.createClass({ getInitialState: function() { return { summary: null, + isGroupPublicised: null, + isUserPrivileged: null, + groupRooms: null, + groupRoomsLoading: null, error: null, editing: false, saving: false, uploadingAvatar: false, membershipBusy: false, publicityBusy: false, + inviterProfile: null, }; }, componentWillMount: function() { - this._changeAvatarComponent = null; - this._initGroupStore(this.props.groupId); + this._matrixClient = MatrixClientPeg.get(); + this._matrixClient.on("Group.myMembership", this._onGroupMyMembership); - MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership); + this._changeAvatarComponent = null; + this._initGroupStore(this.props.groupId, true); }, componentWillUnmount: function() { - MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership); + this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); this._groupStore.removeAllListeners(); }, @@ -445,8 +464,12 @@ export default React.createClass({ this.setState({membershipBusy: false}); }, - _initGroupStore: function(groupId) { - this._groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); + _initGroupStore: function(groupId, firstInit) { + const group = this._matrixClient.getGroup(groupId); + if (group && group.inviter && group.inviter.userId) { + this._fetchInviterProfile(group.inviter.userId); + } + this._groupStore = GroupStoreCache.getGroupStore(this._matrixClient, groupId); this._groupStore.registerListener(() => { const summary = this._groupStore.getSummary(); if (summary.profile) { @@ -458,10 +481,19 @@ export default React.createClass({ } this.setState({ summary, + summaryLoading: !this._groupStore.isStateReady(GroupStore.STATE_KEY.Summary), isGroupPublicised: this._groupStore.getGroupPublicity(), isUserPrivileged: this._groupStore.isUserPrivileged(), + groupRooms: this._groupStore.getGroupRooms(), + groupRoomsLoading: !this._groupStore.isStateReady(GroupStore.STATE_KEY.GroupRooms), + isUserMember: this._groupStore.getGroupMembers().some( + (m) => m.userId === this._matrixClient.credentials.userId, + ), error: null, }); + if (this.props.groupIsNew && firstInit) { + this._onEditClick(); + } }); this._groupStore.on('error', (err) => { this.setState({ @@ -471,6 +503,26 @@ export default React.createClass({ }); }, + _fetchInviterProfile(userId) { + this.setState({ + inviterProfileBusy: true, + }); + this._matrixClient.getProfileInfo(userId).then((resp) => { + this.setState({ + inviterProfile: { + avatarUrl: resp.avatar_url, + displayName: resp.displayname, + }, + }); + }).catch((e) => { + console.error('Error getting group inviter profile', e); + }).finally(() => { + this.setState({ + inviterProfileBusy: false, + }); + }); + }, + _onShowRhsClick: function(ev) { dis.dispatch({ action: 'show_right_panel' }); }, @@ -520,7 +572,7 @@ export default React.createClass({ if (!file) return; this.setState({uploadingAvatar: true}); - MatrixClientPeg.get().uploadContent(file).then((url) => { + this._matrixClient.uploadContent(file).then((url) => { const newProfileForm = Object.assign(this.state.profileForm, { avatar_url: url }); this.setState({ uploadingAvatar: false, @@ -540,7 +592,7 @@ export default React.createClass({ _onSaveClick: function() { this.setState({saving: true}); const savePromise = this.state.isUserPrivileged ? - MatrixClientPeg.get().setGroupProfile(this.props.groupId, this.state.profileForm) : + this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm) : Promise.resolve(); savePromise.then((result) => { this.setState({ @@ -565,7 +617,7 @@ export default React.createClass({ _onAcceptInviteClick: function() { this.setState({membershipBusy: true}); - MatrixClientPeg.get().acceptGroupInvite(this.props.groupId).then(() => { + this._groupStore.acceptGroupInvite().then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { this.setState({membershipBusy: false}); @@ -579,7 +631,7 @@ export default React.createClass({ _onRejectInviteClick: function() { this.setState({membershipBusy: true}); - MatrixClientPeg.get().leaveGroup(this.props.groupId).then(() => { + this._matrixClient.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { this.setState({membershipBusy: false}); @@ -602,7 +654,7 @@ export default React.createClass({ if (!confirmed) return; this.setState({membershipBusy: true}); - MatrixClientPeg.get().leaveGroup(this.props.groupId).then(() => { + this._matrixClient.leaveGroup(this.props.groupId).then(() => { // don't reset membershipBusy here: wait for the membership change to come down the sync }).catch((e) => { this.setState({membershipBusy: false}); @@ -650,6 +702,15 @@ export default React.createClass({ const RoomDetailList = sdk.getComponent('rooms.RoomDetailList'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); + const Spinner = sdk.getComponent('elements.Spinner'); + const ToolTipButton = sdk.getComponent('elements.ToolTipButton'); + + const roomsHelpNode = this.state.editing ? :
; const addRoomRow = this.state.editing ? ( ) :
; + const roomDetailListClassName = classnames({ + "mx_fadable": true, + "mx_fadable_faded": this.state.editing, + }); return
-

{ _t('Rooms') }

+

+ { _t('Rooms') } + { roomsHelpNode } +

{ addRoomRow }
- + { this.state.groupRoomsLoading ? + : + + }
; }, @@ -755,20 +828,37 @@ export default React.createClass({ _getMembershipSection: function() { const Spinner = sdk.getComponent("elements.Spinner"); + const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - const group = MatrixClientPeg.get().getGroup(this.props.groupId); + const group = this._matrixClient.getGroup(this.props.groupId); if (!group) return null; if (group.myMembership === 'invite') { - if (this.state.membershipBusy) { + if (this.state.membershipBusy || this.state.inviterProfileBusy) { return
; } + const httpInviterAvatar = this.state.inviterProfile ? + this._matrixClient.mxcUrlToHttp( + this.state.inviterProfile.avatarUrl, 36, 36, + ) : null; + + let inviterName = group.inviter.userId; + if (this.state.inviterProfile) { + inviterName = this.state.inviterProfile.displayName || group.inviter.userId; + } return
- { _t("%(inviter)s has invited you to join this community", {inviter: group.inviter.userId}) } + + { _t("%(inviter)s has invited you to join this community", { + inviter: inviterName, + }) }
+ { _tJsx( + 'Your community hasn\'t got a Long Description, a HTML page to show to community members.
' + + 'Click here to open settings and give it one!', + [/
/], + [(sub) =>
]) + } +
; } const groupDescEditingClasses = classnames({ "mx_GroupView_groupDesc": true, @@ -848,6 +950,7 @@ export default React.createClass({

{ _t("Long Description (HTML)") }